Merge immortal/main into audit branch

This commit is contained in:
GGThed
2026-08-17 00:28:06 -04:00
3 changed files with 73 additions and 41 deletions
+12 -12
View File
@@ -141,19 +141,23 @@ 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(
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'],
@@ -171,11 +175,7 @@ def add_disponibilities_bulk():
}
)
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'])
+2 -6
View File
@@ -193,9 +193,6 @@
<p class="text-muted">{{ _('Loading...') }}</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-primary" data-action="save-disponibilities">
<i class="fas fa-save"></i> {{ _('Save Disponibilities') }}
</button>
<button type="button" class="btn btn-secondary" data-action="clear-disponibilities">
<i class="fas fa-trash"></i> {{ _('Clear All') }}
</button>
@@ -369,7 +366,7 @@ function saveCoachAvailability() {
const msg = document.createElement('div');
msg.className = 'alert alert-success';
msg.style.marginTop = '10px';
msg.innerHTML = '<i class="fas fa-check"></i> Availability saved!';
msg.innerHTML = '<span><i class="fas fa-check"></i> Availability saved!</span>';
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 = '<i class="fas fa-check"></i> Disponibilities saved successfully!';
msg.innerHTML = '<span><i class="fas fa-check"></i> Disponibilities saved successfully!</span>';
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,
});
+46 -10
View File
@@ -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: