Repository: fossasia/open-event-server

Issue:
Add session status change validations
These are the allowed state changes for session:

Draft -> Pending -> Confirmed, Accepted, Rejected -> Cancelled, Withdrawn

Draft: Not ready to be reviewed, won't be shown to the organizer
Pending: Ready to be reviewed by the organizer and accepted or rejected
Confirmed/Accepted: Don't know what's the distinction between these two
Rejected: Session was rejected
Cancelled: Organizer can cancel the session even after it was accepted. Only an organizer can cancel a session
Withdrawn: A session can be withdrawn by speaker

Of course things will get more complicated with the logistics about actions on each status change.

Currently, there are 2 state change triggers:

- Accepted
  - With Email
  - Without Email
- Rejected
  - With Email
  - Without Email

What other state changes are required? 

Cancelled Email? Contents and who to send it to? Speaker and Attendees?
Withdrawn Email? Contents and who to send it to? Organizers and Attendees?

Action Possible States:

Withdraw: This action can only take place when action is confirmed or accepted, or both?
Cancelled: Same as above

We can start implementing the state change and actions once these queries are clear



Discussion:
**SESSION ACCEPTANCE FLOW**
Draft (Speaker is working on a submission, but has not submitted it. Organizer can see "draft", but cannot accept it.)
↓
Pending (Speaker submitted session. Organizer can see submission and change status to accepted or directly to confirmed)
↲ ↳
↲ 1) Accepted (Organizer has accepted the session. Usually at this stage the organizer communicates with the speaker about details. Some speakers cancel, other speakers confirm their session. Speaker sees in the system that their status has changed to "Accepted".)
↳ 2) Rejected: (Session is rejected by organizer. Speaker can see the status.)
↲ ↳
↲ 1) Confirmed (Organizers confirmed the session of the speakers. Speakers see the status "Confirmed".)
↳ 2) Canceled (Organizers cancel the speaker session for some reason. Speakers see the status "Canceled".)
↳ 3) Withdrawn (Alternatively organizers or speakers have the option to change to "Withdrawn" status, which is technically the same as canceled. However, it helps in the organization sometimes to indicate the reason for a status change. As a matter of fact canceled or withdrawn status can be chosen at any time as well in the flow.)

**SCHEDULER**
For the scheduler. Which session can be added to the scheduler?
* Accepted
* Confirmed
For now it seems to work out best if both "Accepted" and "Confirmed" sessions could be added to the scheduler.

Does this make sense?
> Withdraw: This action can only take place when action is confirmed or accepted, or both?

The speaker can withdraw at any time after he/she submitted. (It would not make sense to withdraw in draft mode.)
The organizer can also mark a session "Withdrawn" at any time after the speaker submitted it. Sometimes speakers only write an email and dont login to perform this action. Therefore organizers should also have the right to do that.

> Cancelled: Same as above

Sessions can only be cancelled after the status "Accepted" or "Confirmed". A session that is in pending mode cannot be canceled as it has not been in the acceptance list or on the scheduler.

>Rejected:

A session that was pending can be changed to "Rejected". Sometimes speakers cancel and slots become available. Rejected sessions can then be accepted and confirmed later. So, it should be possible to allow the organizer to change the mode from "Rejected" to "Accepted" or "Confirmed".
> Emails:

We need email actions for each state. Let's work this out in a separate issue and focus on the flow in this issue. If it is easier maybe just allow status changes without emails for now.
Everything else makes sense, but what operations should be allowed in withdrawn mode? Can a speaker put it back in pending mode? Can an organizer change status from withdrawn to accepted, confirmed, rejected?

What about organizer changing accepted, rejected, confirmed, canceled sessions to pending mode? Should it be allowed?
These are the possible transition states as of now. If anything needs to be changed, please tell

```python
SESSION_STATE_DICT = {
    'organizer': {
        'draft': {}, # No state change allowed
        'pending': {'withdrawn': True, 'accepted': True, 'rejected': True, 'confirmed': True},
        'accepted': {'withdrawn': True, 'rejected': True, 'confirmed': True, 'canceled': True},
        'confirmed': {'withdrawn': True, 'accepted': True, 'rejected': True, 'canceled': True},
        'rejected': {'withdrawn': True, 'accepted': True, 'confirmed': True},
        'canceled': {'withdrawn': True, 'accepted': True, 'rejected': True, 'confirmed': True},
        'withdrawn': {'accepted': True, 'rejected': True, 'confirmed': True}
    },
    'speaker': {
        'draft': {'pending': True},
        'pending': {'withdrawn': True},
        'accepted': {'withdrawn': True},
        'confirmed': {'withdrawn': True},
        'rejected': {'withdrawn': True},
        'canceled': {'withdrawn': True},
        'withdrawn': {'pending': True}
    }
}
```

Resolving patch:
diff --git a/app/api/helpers/scheduled_jobs.py b/app/api/helpers/scheduled_jobs.py
--- a/app/api/helpers/scheduled_jobs.py
+++ b/app/api/helpers/scheduled_jobs.py
@@ -21,7 +21,7 @@
 )
 from app.api.helpers.query import get_user_event_roles_by_role_name
 from app.api.helpers.storage import UPLOAD_PATHS
-from app.api.helpers.utilities import monthdelta, make_dict
+from app.api.helpers.utilities import make_dict, monthdelta
 from app.instance import celery
 from app.models import db
 from app.models.event import Event
diff --git a/app/api/schema/sessions.py b/app/api/schema/sessions.py
--- a/app/api/schema/sessions.py
+++ b/app/api/schema/sessions.py
@@ -64,13 +64,6 @@ def validate_fields(self, data, original_data):
                     "starts-at should be after current date-time",
                 )
 
-        if data.get('state') and data['state'] not in ('draft', 'pending'):
-            if not has_access('is_coorganizer', event_id=data['event']):
-                raise ForbiddenError(
-                    {'pointer': '/data/attributes/state'},
-                    'Co-organizer access is required.',
-                )
-
         if 'microlocation' in data:
             if not has_access('is_coorganizer', event_id=data['event']):
                 raise ForbiddenError(
@@ -96,9 +89,17 @@ def validate_fields(self, data, original_data):
     signup_url = fields.Url(allow_none=True)
     state = fields.Str(
         validate=validate.OneOf(
-            choices=["pending", "accepted", "confirmed", "rejected", "draft", "cancelled", "withdrawn"]
+            choices=[
+                "pending",
+                "accepted",
+                "confirmed",
+                "rejected",
+                "draft",
+                "canceled",
+                "withdrawn",
+            ]
         ),
-        allow_none=True,
+        allow_none=False,
         missing='draft',
     )
     created_at = fields.DateTime(dump_only=True)
diff --git a/app/api/sessions.py b/app/api/sessions.py
--- a/app/api/sessions.py
+++ b/app/api/sessions.py
@@ -172,6 +172,48 @@ def query(self, view_kwargs):
     data_layer = {'session': db.session, 'model': Session, 'methods': {'query': query}}
 
 
+SESSION_STATE_DICT = {
+    'organizer': {
+        'draft': {},  # No state change allowed
+        'pending': {
+            'withdrawn': True,
+            'accepted': True,
+            'rejected': True,
+            'confirmed': True,
+        },
+        'accepted': {
+            'withdrawn': True,
+            'rejected': True,
+            'confirmed': True,
+            'canceled': True,
+        },
+        'confirmed': {
+            'withdrawn': True,
+            'accepted': True,
+            'rejected': True,
+            'canceled': True,
+        },
+        'rejected': {'withdrawn': True, 'accepted': True, 'confirmed': True},
+        'canceled': {
+            'withdrawn': True,
+            'accepted': True,
+            'rejected': True,
+            'confirmed': True,
+        },
+        'withdrawn': {'accepted': True, 'rejected': True, 'confirmed': True},
+    },
+    'speaker': {
+        'draft': {'pending': True},
+        'pending': {'withdrawn': True},
+        'accepted': {'withdrawn': True},
+        'confirmed': {'withdrawn': True},
+        'rejected': {'withdrawn': True},
+        'canceled': {'withdrawn': True},
+        'withdrawn': {'pending': True},
+    },
+}
+
+
 class SessionDetail(ResourceDetail):
     """
     Session detail by id
@@ -197,11 +239,11 @@ def before_update_object(self, session, data, view_kwargs):
         :param view_kwargs:
         :return:
         """
+        is_organizer = has_access('is_admin') or has_access(
+            'is_organizer', event_id=session.event_id
+        )
         if session.is_locked:
-            if not (
-                has_access('is_admin')
-                or has_access('is_organizer', event_id=session.event_id)
-            ):
+            if not is_organizer:
                 raise ForbiddenError(
                     {'source': '/data/attributes/is-locked'},
                     "You don't have enough permissions to change this property",
@@ -213,6 +255,21 @@ def before_update_object(self, session, data, view_kwargs):
                 "Locked sessions cannot be edited",
             )
 
+        new_state = data.get('state')
+        if new_state and new_state != session.state:
+            # State change detected. Verify that state change is allowed
+            key = 'speaker'
+            if is_organizer:
+                key = 'organizer'
+            state_dict = SESSION_STATE_DICT[key]
+            try:
+                state_dict[session.state][new_state]
+            except KeyError:
+                raise ForbiddenError(
+                    {'pointer': '/data/attributes/state'},
+                    f'You cannot change a session state from "{session.state}" to "{new_state}"',
+                )
+
         if not can_edit_after_cfs_ends(session.event_id):
             raise ForbiddenError(
                 {'source': ''}, "Cannot edit session after the call for speaker is ended"


Test patch:
diff --git a/tests/all/conftest.py b/tests/all/conftest.py
--- a/tests/all/conftest.py
+++ b/tests/all/conftest.py
@@ -62,6 +62,19 @@ def user(db):
     return user
 
 
+@pytest.fixture
+def admin_user(db):
+    user = UserFactory(is_admin=True)
+    db.session.commit()
+
+    return user
+
+
 @pytest.fixture
 def jwt(user):
     return {'Authorization': "JWT " + create_access_token(user.id, fresh=True)}
+
+
+@pytest.fixture
+def admin_jwt(admin_user):
+    return {'Authorization': "JWT " + create_access_token(admin_user.id, fresh=True)}
diff --git a/tests/all/integration/api/session/test_session_api.py b/tests/all/integration/api/session/test_session_forms_api.py
similarity index 100%
rename from tests/all/integration/api/session/test_session_api.py
rename to tests/all/integration/api/session/test_session_forms_api.py
diff --git a/tests/all/integration/api/session/test_session_state_api.py b/tests/all/integration/api/session/test_session_state_api.py
new file mode 100644
--- /dev/null
+++ b/tests/all/integration/api/session/test_session_state_api.py
@@ -0,0 +1,189 @@
+import json
+
+import pytest
+
+from app.api.helpers.db import get_or_create
+from app.models.role import Role
+from app.models.users_events_role import UsersEventsRoles
+from tests.factories.session import SessionSubFactory
+from tests.factories.speakers_call import SpeakersCallSubFactory
+from tests.factories.user import UserFactory
+
+
+def get_session(db, user, event_owner=True, **kwargs):
+    new_user = UserFactory(is_admin=False, is_verified=False)
+
+    if event_owner:
+        owner = user
+        creator = new_user
+    else:
+        owner = new_user
+        creator = user
+    session = SessionSubFactory(creator_id=creator.id, **kwargs)
+    role, _ = get_or_create(Role, name='owner', title_name='Owner')
+    UsersEventsRoles(user=owner, event=session.event, role=role)
+    SpeakersCallSubFactory(event=session.event)
+    db.session.commit()
+
+    return session
+
+
+def _test_state_change(
+    db, client, user, jwt, new_state, state, event_owner=True, error=True
+):
+    session = get_session(db, user, event_owner=event_owner, state=state)
+
+    data = json.dumps(
+        {
+            'data': {
+                'type': 'session',
+                'id': str(session.id),
+                "attributes": {"state": new_state},
+            }
+        }
+    )
+
+    response = client.patch(
+        f'/v1/sessions/{session.id}',
+        content_type='application/vnd.api+json',
+        headers=jwt,
+        data=data,
+    )
+
+    db.session.refresh(session)
+
+    if error:
+        assert response.status_code == 403
+        assert json.loads(response.data) == {
+            'errors': [
+                {
+                    'detail': f'You cannot change a session state from "{state}" to "{new_state}"',
+                    'source': {'pointer': '/data/attributes/state'},
+                    'status': 403,
+                    'title': 'Access Forbidden',
+                }
+            ],
+            'jsonapi': {'version': '1.0'},
+        }
+
+        assert session.state == state
+    else:
+        assert response.status_code == 200
+        assert session.state == new_state
+
+
+@pytest.mark.parametrize('new_state', ['accepted', 'confirmed', 'rejected'])
+def test_draft_organizer_error(db, client, user, jwt, new_state, state='draft'):
+    _test_state_change(db, client, user, jwt, new_state, state=state)
+
+
+def _create_permutations(statesA, statesB):
+    results = []
+    for stateA in statesA:
+        for stateB in statesB:
+            results.append((stateA, stateB))
+    return results
+
+
+states = _create_permutations(
+    ['draft', 'pending', 'accepted', 'confirmed', 'rejected'],
+    ['accepted', 'confirmed', 'rejected', 'canceled'],
+)
+
+
+@pytest.mark.parametrize('state,new_state', states)
+def test_draft_speaker_error(db, client, user, jwt, new_state, state):
+    if state == new_state:
+        return
+    _test_state_change(db, client, user, jwt, new_state, state=state, event_owner=False)
+
+
+@pytest.mark.parametrize('new_state', ['pending'])
+def test_draft_speaker_allow(db, client, user, jwt, new_state, state='draft'):
+    _test_state_change(
+        db, client, user, jwt, new_state, state=state, event_owner=False, error=False
+    )
+
+
+states = _create_permutations(
+    ['pending', 'accepted', 'confirmed', 'rejected', 'canceled'], ['withdrawn']
+)
+
+
+@pytest.mark.parametrize('state,new_state', states)
+def test_withdraw_speaker_allow(db, client, user, jwt, new_state, state):
+    _test_state_change(
+        db, client, user, jwt, new_state, state=state, event_owner=False, error=False
+    )
+
+
+states = _create_permutations(['withdrawn'], ['pending'])
+
+
+@pytest.mark.parametrize('state,new_state', states)
+def test_revert_withdraw_speaker_allow(db, client, user, jwt, new_state, state):
+    _test_state_change(
+        db, client, user, jwt, new_state, state=state, event_owner=False, error=False
+    )
+
+
+def test_withdraw_speaker_error(
+    db, client, user, jwt, new_state='withdrawn', state='draft'
+):
+    _test_state_change(db, client, user, jwt, new_state, state=state, event_owner=False)
+
+
+states = _create_permutations(
+    ['pending', 'accepted', 'confirmed', 'rejected', 'canceled'], ['withdrawn']
+)
+
+
+@pytest.mark.parametrize('state,new_state', states)
+def test_withdraw_organizer_allow(db, client, user, jwt, new_state, state):
+    _test_state_change(db, client, user, jwt, new_state, state=state, error=False)
+
+
+states = _create_permutations(['withdrawn'], ['accepted', 'confirmed', 'rejected'])
+
+
+@pytest.mark.parametrize('state,new_state', states)
+def test_revert_withdraw_organizer_allow(db, client, user, jwt, new_state, state):
+    _test_state_change(db, client, user, jwt, new_state, state=state, error=False)
+
+
+states = _create_permutations(['pending'], ['accepted', 'confirmed', 'rejected'])
+
+
+@pytest.mark.parametrize('state,new_state', states)
+def test_pending_organizer_allow(db, client, user, jwt, new_state, state):
+    _test_state_change(db, client, user, jwt, new_state, state=state, error=False)
+
+
+states = _create_permutations(['rejected'], ['accepted', 'confirmed'])
+
+
+@pytest.mark.parametrize('state,new_state', states)
+def test_revert_rejected_organizer_allow(db, client, user, jwt, new_state, state):
+    _test_state_change(db, client, user, jwt, new_state, state=state, error=False)
+
+
+states = _create_permutations(['accepted', 'confirmed'], ['canceled'])
+
+
+@pytest.mark.parametrize('state,new_state', states)
+def test_canceled_organizer_allow(db, client, user, jwt, new_state, state):
+    _test_state_change(db, client, user, jwt, new_state, state=state, error=False)
+
+
+states = _create_permutations(['pending', 'rejected', 'withdrawn'], ['canceled'])
+
+
+@pytest.mark.parametrize('state,new_state', states)
+def test_canceled_organizer_disallow(db, client, user, jwt, new_state, state):
+    _test_state_change(db, client, user, jwt, new_state, state=state)
+
+
+def test_confirmed_organizer_allow(
+    db, client, user, jwt, new_state='canceled', state='confirmed'
+):
+    _test_state_change(db, client, user, jwt, new_state, state=state, error=False)