diff --git a/app/routes/users/availability.py b/app/routes/users/availability.py index ee8fe2f..6f30fb2 100644 --- a/app/routes/users/availability.py +++ b/app/routes/users/availability.py @@ -141,41 +141,41 @@ def add_disponibility(): @json_endpoint @login_required def add_disponibilities_bulk(): - """Add multiple disponibility blocks at once.""" + """Replace the current player's disponibility blocks atomically.""" data = request.get_json(silent=True) or {} accepted, rejected = _load_slots(data.get('slots')) + if rejected: + return jsonify( + { + 'error': 'Invalid slots; nothing was changed.', + 'rejected': rejected, + } + ), 400 + + PlayerDisponibility.query.filter_by(player_id=current_user.id).delete() + created = [] for slot in accepted: start_time = slot['start_time'] - existing = PlayerDisponibility.query.filter_by( + disponibility = PlayerDisponibility( player_id=current_user.id, day_of_week=slot['day_of_week'], start_time=start_time, - ).first() - if not existing: - disponibility = PlayerDisponibility( - player_id=current_user.id, - day_of_week=slot['day_of_week'], - start_time=start_time, - end_time=slot_end(start_time), - ) - db.session.add(disponibility) - db.session.flush() - created.append( - { - 'id': disponibility.id, - 'day_of_week': disponibility.day_of_week, - 'day_name': day_name(disponibility.day_of_week), - 'start_time': disponibility.start_time.strftime('%H:%M'), - } - ) + end_time=slot_end(start_time), + ) + db.session.add(disponibility) + db.session.flush() + created.append( + { + 'id': disponibility.id, + 'day_of_week': disponibility.day_of_week, + 'day_name': day_name(disponibility.day_of_week), + 'start_time': disponibility.start_time.strftime('%H:%M'), + } + ) db.session.commit() - # `rejected` is reported rather than swallowed. What was accepted is - # still saved — dropping a whole batch because one cell was malformed - # would be its own kind of surprise — but the client can now tell the - # difference between "nine slots saved" and "ten sent, nine saved". - return jsonify({'success': True, 'created': created, 'rejected': rejected}) + return jsonify({'success': True, 'created': created, 'rejected': []}) @users_bp.route('/disponibilities/clear', methods=['POST']) diff --git a/app/templates/pages/profile.html b/app/templates/pages/profile.html index 4b71bc1..affd9c1 100644 --- a/app/templates/pages/profile.html +++ b/app/templates/pages/profile.html @@ -193,9 +193,6 @@

{{ _('Loading...') }}

- @@ -369,7 +366,7 @@ function saveCoachAvailability() { const msg = document.createElement('div'); msg.className = 'alert alert-success'; msg.style.marginTop = '10px'; - msg.innerHTML = ' Availability saved!'; + msg.innerHTML = ' Availability saved!'; document.getElementById('availability-grid').appendChild(msg); setTimeout(() => msg.remove(), 3000); } @@ -571,7 +568,7 @@ function saveDisponibilities() { var msg = document.createElement('div'); msg.className = 'alert alert-success'; msg.style.marginTop = '10px'; - msg.innerHTML = ' Disponibilities saved successfully!'; + msg.innerHTML = ' Disponibilities saved successfully!'; document.getElementById('disponibilities-grid').appendChild(msg); setTimeout(function() { msg.remove(); }, 3000); } @@ -610,7 +607,6 @@ document.addEventListener('DOMContentLoaded', function() { // dispatched by the delegated listener in main.js. This replaces inline // onclick attributes, which no CSP nonce is able to authorise. registerActions({ - 'save-disponibilities': saveDisponibilities, 'clear-disponibilities': clearDisponibilities, 'clear-availability': clearAllAvailability, }); diff --git a/tests/test_timeslots.py b/tests/test_timeslots.py index b044a24..c371411 100644 --- a/tests/test_timeslots.py +++ b/tests/test_timeslots.py @@ -92,7 +92,7 @@ class TestPlayerAvailability: assert PlayerDisponibility.query.count() == 0 -class TestBulkAvailabilityReportsWhatItDropped: +class TestBulkAvailabilityReplacementIsAtomic: def test_valid_slots_are_saved(self, app, client, as_role): from app.models import PlayerDisponibility @@ -107,28 +107,64 @@ class TestBulkAvailabilityReportsWhatItDropped: with app.app_context(): assert PlayerDisponibility.query.count() == 1 - def test_a_dropped_slot_is_named(self, app, client, as_role): - """It used to `continue` and answer success, so the client could not - tell nine saved from ten sent.""" + def test_a_good_payload_replaces_the_slots(self, app, client, as_role): from app.models import PlayerDisponibility - as_role('player') + player_id = as_role('player') + client.post( + '/users/disponibilities/add_bulk', + json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]}, + ) + + response = client.post( + '/users/disponibilities/add_bulk', + json={'slots': [{'day_of_week': 3, 'start_time': '18:00'}]}, + ) + + assert response.status_code == 200 + with app.app_context(): + slots = PlayerDisponibility.query.filter_by(player_id=player_id).all() + assert [(s.day_of_week, s.start_time) for s in slots] == [(3, time(18, 0))] + + def test_an_empty_payload_clears_the_slots(self, app, client, as_role): + from app.models import PlayerDisponibility + + player_id = as_role('player') + client.post( + '/users/disponibilities/add_bulk', + json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]}, + ) + + response = client.post('/users/disponibilities/add_bulk', json={'slots': []}) + + assert response.status_code == 200 + with app.app_context(): + assert PlayerDisponibility.query.filter_by(player_id=player_id).count() == 0 + + def test_a_malformed_slot_changes_nothing(self, app, client, as_role): + from app.models import PlayerDisponibility + + player_id = as_role('player') + client.post( + '/users/disponibilities/add_bulk', + json={'slots': [{'day_of_week': 1, 'start_time': '09:00'}]}, + ) response = client.post( '/users/disponibilities/add_bulk', json={ 'slots': [ - {'day_of_week': 1, 'start_time': '09:00'}, + {'day_of_week': 3, 'start_time': '18:00'}, {'day_of_week': 1, 'start_time': 'nope'}, ] }, ) - body = response.get_json() - assert len(body['created']) == 1 - assert len(body['rejected']) == 1, 'the caller must learn a slot was dropped' + assert response.status_code == 400 + assert len(response.get_json()['rejected']) == 1 with app.app_context(): - assert PlayerDisponibility.query.count() == 1 + slots = PlayerDisponibility.query.filter_by(player_id=player_id).all() + assert [(s.day_of_week, s.start_time) for s in slots] == [(1, time(9, 0))] class TestCoachAvailabilityIsNotWipedByABadPayload: