Files
PyRIGS/RIGS/tests/test_unit.py
Joe Banks a29384a314 Bootstrap 5 & remove jQuery (#661)
* build: move frontend assets to Bootstrap 5.3 and Tom Select

* feat: migrate templates and scripts to Bootstrap 5, dropping jQuery

Templates
- Rename Bootstrap 4 classes and data attributes (data-bs-*, ms-/me-/ps-/pe-,
  text-start/end, form-group -> mb-3, input-group wrappers removed, badges,
  custom controls -> form-check/form-select, media -> d-flex, btn-close, ...)
- Dark mode now uses Bootstrap 5.3 colour modes (data-bs-theme on <html>),
  set from <head> to avoid a flash of the light theme
- Remove the Bootstrap 4 /bootstrap/ demo page
- Contextual table classes (table-success etc.) still work as plain
  backgrounds for the rigboard key

JavaScript
- Remove jQuery: src.js/interaction.js and every inline template script are
  rewritten in plain JS on top of small helpers (helpers.js, bundled with
  Bootstrap into jpop.js so inline scripts can use them)
- Replace bootstrap-select/ajax-bootstrap-select with Tom Select. The remote
  search now sends the `q` parameter and reads `text`, which is what the
  API actually uses (the old picker sent `term` and read `label`)
- HTML fragments are fetched with an X-Requested-With header so views keep
  rendering their modal variants, and fetched scripts are re-executed
- Remove the Bootstrap 4 plugin script includes

* fix: stop rigboard header buttons stretching to full width under Bootstrap 5

* fix: restore dark-mode brand colours and contextual table colours under Bootstrap 5

* fix: brighten dark-mode links and make coloured card outlines visible

* fix: calendar layout and grid borders, navbar search button width, guard missing modal links

* fix: readable text in dark-mode Tom Select inputs and placeholders, restore form-control column widths

* build: split the Dockerfile into cached stages with prod and dev targets

* deploy: make compose.yml development-only with live reload, and document it

* fix: keep badge text colour for links inside badges

* fix: restore table-bordered grid lines in dark mode

* fix: include Bootstrap's progress styles and keep progress labels readable
2026-10-02 16:28:11 +01:00

486 lines
18 KiB
Python

import os
import pytest
from datetime import date
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.test import TestCase
from django.utils.safestring import SafeText
from RIGS.templatetags.markdown_tags import markdown_filter
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from pytest_django.asserts import assertRedirects, assertNotContains, assertContains
from PyRIGS.tests.base import assert_times_almost_equal, assert_oembed, login
from django.forms.models import model_to_dict
from RIGS import forms, models
pytestmark = pytest.mark.django_db
class TestAdminMergeObjects(TestCase):
@classmethod
def setUpTestData(cls):
cls.profile = models.Profile.objects.create(
username="testuser1", email="1@test.com", is_superuser=True, is_active=True, is_staff=True
)
cls.persons = {
1: models.Person.objects.create(name="Person 1"),
2: models.Person.objects.create(name="Person 2"),
3: models.Person.objects.create(name="Person 3"),
}
cls.organisations = {
1: models.Organisation.objects.create(name="Organisation 1"),
2: models.Organisation.objects.create(name="Organisation 2"),
3: models.Organisation.objects.create(name="Organisation 3"),
}
cls.venues = {
1: models.Venue.objects.create(name="Venue 1"),
2: models.Venue.objects.create(name="Venue 2"),
3: models.Venue.objects.create(name="Venue 3"),
}
cls.events = {
1: models.Event.objects.create(
name="TE E1",
start_date=date.today(),
person=cls.persons[1],
organisation=cls.organisations[3],
venue=cls.venues[2],
),
2: models.Event.objects.create(
name="TE E2",
start_date=date.today(),
person=cls.persons[2],
organisation=cls.organisations[2],
venue=cls.venues[3],
),
3: models.Event.objects.create(
name="TE E3",
start_date=date.today(),
person=cls.persons[3],
organisation=cls.organisations[1],
venue=cls.venues[1],
),
4: models.Event.objects.create(
name="TE E4",
start_date=date.today(),
person=cls.persons[3],
organisation=cls.organisations[3],
venue=cls.venues[3],
),
}
def setUp(self):
self.profile.set_password("testuser")
self.profile.save()
self.assertTrue(self.client.login(username=self.profile.username, password="testuser"))
def test_merge_confirmation(self):
change_url = reverse("admin:RIGS_venue_changelist")
data = {"action": "merge", "_selected_action": [str(val.pk) for key, val in self.venues.items()]}
response = self.client.post(change_url, data, follow=True)
self.assertContains(response, "The following objects will be merged")
for key, venue in self.venues.items():
self.assertContains(response, venue.name)
def test_merge_no_master(self):
change_url = reverse("admin:RIGS_venue_changelist")
data = {
"action": "merge",
"_selected_action": [str(val.pk) for key, val in self.venues.items()],
"post": "yes",
}
response = self.client.post(change_url, data, follow=True)
self.assertContains(response, "An error occured")
def test_venue_merge(self):
change_url = reverse("admin:RIGS_venue_changelist")
data = {
"action": "merge",
"_selected_action": [str(self.venues[1].pk), str(self.venues[2].pk)],
"post": "yes",
"master": self.venues[1].pk,
}
response = self.client.post(change_url, data, follow=True)
self.assertContains(response, "Objects successfully merged")
self.assertContains(response, self.venues[1].name)
# Check the master copy still exists
self.assertTrue(models.Venue.objects.get(pk=self.venues[1].pk))
# Check the un-needed venue has been disposed of
self.assertRaises(ObjectDoesNotExist, models.Venue.objects.get, pk=self.venues[2].pk)
# Check the one we didn't delete is still there
self.assertEqual(models.Venue.objects.get(pk=self.venues[3].pk), self.venues[3])
# Check the events have been moved to the master venue
for key, event in self.events.items():
updatedEvent = models.Event.objects.get(pk=event.pk)
if event.venue == self.venues[3]: # The one we left in place
continue
self.assertEqual(updatedEvent.venue, self.venues[1])
def test_person_merge(self):
change_url = reverse("admin:RIGS_person_changelist")
data = {
"action": "merge",
"_selected_action": [str(self.persons[1].pk), str(self.persons[2].pk)],
"post": "yes",
"master": self.persons[1].pk,
}
response = self.client.post(change_url, data, follow=True)
self.assertContains(response, "Objects successfully merged")
self.assertContains(response, self.persons[1].name)
# Check the master copy still exists
self.assertTrue(models.Person.objects.get(pk=self.persons[1].pk))
# Check the un-needed people have been disposed of
self.assertRaises(ObjectDoesNotExist, models.Person.objects.get, pk=self.persons[2].pk)
# Check the one we didn't delete is still there
self.assertEqual(models.Person.objects.get(pk=self.persons[3].pk), self.persons[3])
# Check the events have been moved to the master person
for key, event in self.events.items():
updatedEvent = models.Event.objects.get(pk=event.pk)
if event.person == self.persons[3]: # The one we left in place
continue
self.assertEqual(updatedEvent.person, self.persons[1])
def test_organisation_merge(self):
change_url = reverse("admin:RIGS_organisation_changelist")
data = {
"action": "merge",
"_selected_action": [str(self.organisations[1].pk), str(self.organisations[2].pk)],
"post": "yes",
"master": self.organisations[1].pk,
}
response = self.client.post(change_url, data, follow=True)
self.assertContains(response, "Objects successfully merged")
self.assertContains(response, self.organisations[1].name)
# Check the master copy still exists
self.assertTrue(models.Organisation.objects.get(pk=self.organisations[1].pk))
# Check the un-needed organisations have been disposed of
self.assertRaises(ObjectDoesNotExist, models.Organisation.objects.get, pk=self.organisations[2].pk)
# Check the one we didn't delete is still there
self.assertEqual(models.Organisation.objects.get(pk=self.organisations[3].pk), self.organisations[3])
# Check the events have been moved to the master organisation
for key, event in self.events.items():
updatedEvent = models.Event.objects.get(pk=event.pk)
if event.organisation == self.organisations[3]: # The one we left in place
continue
self.assertEqual(updatedEvent.organisation, self.organisations[1])
class TestInvoiceDelete(TestCase):
@classmethod
def setUpTestData(cls):
cls.profile = models.Profile.objects.create(
username="testuser1", email="1@test.com", is_superuser=True, is_active=True, is_staff=True
)
cls.vatrate = models.VatRate.objects.create(start_at="2014-03-05", rate=0.20, comment="test1")
cls.events = {
1: models.Event.objects.create(name="TE E1", start_date=date.today()),
2: models.Event.objects.create(name="TE E2", start_date=date.today()),
}
cls.invoices = {
1: models.Invoice.objects.create(event=cls.events[1]),
2: models.Invoice.objects.create(event=cls.events[2]),
}
cls.payments = {
1: models.Payment.objects.create(
invoice=cls.invoices[1], date=date.today(), amount=12.34, method=models.Payment.CASH
)
}
def setUp(self):
self.profile.set_password("testuser")
self.profile.save()
self.assertTrue(self.client.login(username=self.profile.username, password="testuser"))
def test_invoice_delete_allowed(self):
request_url = reverse("invoice_delete", kwargs={"pk": self.invoices[2].pk})
response = self.client.get(request_url, follow=True)
self.assertContains(response, "Are you sure")
# Check the invoice still exists
self.assertTrue(models.Invoice.objects.get(pk=self.invoices[2].pk))
# Actually delete it
self.client.post(request_url, follow=True)
# Check the invoice is deleted
self.assertRaises(ObjectDoesNotExist, models.Invoice.objects.get, pk=self.invoices[2].pk)
def test_invoice_delete_not_allowed(self):
request_url = reverse("invoice_delete", kwargs={"pk": self.invoices[1].pk})
response = self.client.get(request_url, follow=True)
self.assertContains(response, "To delete an invoice, delete the payments first.")
# Check the invoice still exists
self.assertTrue(models.Invoice.objects.get(pk=self.invoices[1].pk))
# Try to actually delete it
self.client.post(request_url, follow=True)
# Check this didn't work
self.assertTrue(models.Invoice.objects.get(pk=self.invoices[1].pk))
class TestPrintPaperwork(TestCase):
@classmethod
def setUpTestData(cls):
cls.profile = models.Profile.objects.create(
username="testuser1", email="1@test.com", is_superuser=True, is_active=True, is_staff=True
)
cls.events = {
1: models.Event.objects.create(
name="TE E1",
start_date=date.today(),
description="This is an event description\nthat for a very specific reason spans two lines.",
),
}
cls.invoices = {
1: models.Invoice.objects.create(event=cls.events[1]),
}
def setUp(self):
self.profile.set_password("testuser")
self.profile.save()
self.assertTrue(self.client.login(username=self.profile.username, password="testuser"))
def test_print_paperwork_success(self):
request_url = reverse("event_print", kwargs={"pk": self.events[1].pk})
response = self.client.get(request_url, follow=True)
self.assertEqual(response.status_code, 200)
def test_print_invoice_success(self):
request_url = reverse("invoice_print", kwargs={"pk": self.invoices[1].pk})
response = self.client.get(request_url, follow=True)
self.assertEqual(response.status_code, 200)
def test_login_redirect(client, django_user_model):
request_url = reverse("event_embed", kwargs={"pk": 1})
expected_url = f"{reverse('login_embed')}?next={request_url}"
# Request the page and check it redirects
response = client.get(request_url, follow=True)
assertRedirects(response, expected_url, status_code=302, target_status_code=200)
# Now login
login(client, django_user_model)
# And check that it no longer redirects
response = client.get(request_url, follow=True)
assert len(response.redirect_chain) == 0
def test_login_cookie_warning(client):
login_url = reverse("login_embed")
response = client.post(login_url, follow=True)
assertContains(response, "Cookies do not seem to be enabled")
def test_xframe_headers(admin_client, basic_event):
event_url = reverse("event_embed", kwargs={"pk": basic_event.pk})
login_url = reverse("login_embed")
response = admin_client.get(event_url, follow=True)
with pytest.raises(KeyError):
response.headers["X-Frame-Options"]
response = admin_client.get(login_url, follow=True)
with pytest.raises(KeyError):
response.headers["X-Frame-Options"]
def test_oembed(client, basic_event):
event_url = reverse("event_detail", kwargs={"pk": basic_event.pk})
event_embed_url = reverse("event_embed", kwargs={"pk": basic_event.pk})
oembed_url = reverse("event_oembed", kwargs={"pk": basic_event.pk})
alt_oembed_url = reverse("event_oembed", kwargs={"pk": 999})
alt_event_embed_url = reverse("event_embed", kwargs={"pk": 999})
assert_oembed(alt_event_embed_url, alt_oembed_url, client, event_embed_url, event_url, oembed_url)
def search(client, url, found, notfound, arguments):
for argument in arguments:
query = getattr(found, argument)
request_url = "%s?q=%s" % (reverse_lazy(url), query)
response = client.get(request_url, follow=True)
assertContains(response, getattr(found, "name"))
assertNotContains(response, getattr(notfound, "name"))
def test_search(admin_client):
persons = {
1: models.Person.objects.create(name="Right Person", phone="1234"),
2: models.Person.objects.create(name="Wrong Person", phone="5678"),
}
organisations = {
1: models.Organisation.objects.create(name="Right Organisation", email="test@example.com"),
2: models.Organisation.objects.create(name="Wrong Organisation", email="check@fake.co.uk"),
}
venues = {
1: models.Venue.objects.create(name="Right Venue", address="1 Test Street, EX1"),
2: models.Venue.objects.create(name="Wrong Venue", address="2 Check Way, TS2"),
}
events = {
1: models.Event.objects.create(
name="Right Event",
start_date=date.today(),
venue=venues[1],
person=persons[1],
organisation=organisations[1],
),
2: models.Event.objects.create(
name="Wrong Event",
start_date=date.today(),
venue=venues[2],
person=persons[2],
organisation=organisations[2],
),
}
search(admin_client, "event_archive", events[1], events[2], ["name", "id"])
search(admin_client, "person_list", persons[1], persons[2], ["name", "id", "phone"])
search(admin_client, "organisation_list", organisations[1], organisations[2], ["name", "id", "email"])
search(admin_client, "venue_list", venues[1], venues[2], ["name", "id", "address"])
def test_hs_list(admin_client, basic_event):
request_url = reverse("hs_list")
response = admin_client.get(request_url, follow=True)
assertContains(response, basic_event.name)
# assertContains(response, events[2].name)
assertContains(response, "Create")
def review(client, profile, obj, request_url):
time = timezone.now()
response = client.get(reverse(request_url, kwargs={"pk": obj.pk}), follow=True)
obj.refresh_from_db()
assertContains(response, "Reviewed by")
assertContains(response, profile.name)
assert_times_almost_equal(time, obj.reviewed_at)
def test_ra_review(admin_client, admin_user, ra):
review(admin_client, admin_user, ra, "ra_review")
def test_checklist_review(admin_client, admin_user, checklist):
review(admin_client, admin_user, checklist, "ec_review")
def test_ra_redirect(admin_client, admin_user, ra):
request_url = reverse("event_ra", kwargs={"pk": ra.event.pk})
expected_url = reverse("ra_edit", kwargs={"pk": ra.pk})
response = admin_client.get(request_url, follow=True)
assertRedirects(response, expected_url, status_code=302, target_status_code=200)
class TestMarkdownTemplateTags(TestCase):
with open(os.path.join(settings.BASE_DIR, "RIGS/tests/sample.md"), encoding="utf-8") as f:
markdown = f.read()
def test_html_safe(self):
html = markdown_filter(self.markdown)
self.assertIsInstance(html, SafeText)
def test_img_strip(self):
rml = markdown_filter(self.markdown, "rml")
self.assertNotIn("<img", rml)
def test_code(self):
rml = markdown_filter(self.markdown, "rml")
self.assertIn('<font face="Courier">monospace</font>', rml)
def test_blockquote(self):
rml = markdown_filter(self.markdown, "rml")
self.assertIn("<pre>\nBlock quotes", rml)
def test_lists(self):
rml = markdown_filter(self.markdown, "rml")
self.assertIn("<li><para>second item</para></li>", rml) # <ol>
self.assertIn("<li><para>that one</para></li>", rml) # <ul>
def test_in_print(self):
event = models.Event.objects.create(
name="MD Print Test",
description=self.markdown,
start_date="2016-01-01",
)
models.EventItem.objects.create(
event=event, name="TI I1", quantity=1, cost=1.00, order=1, description="* test \n * test \n * test"
)
user = models.Profile.objects.create(
username="RML test",
is_superuser=True, # Don't care about permissions
is_active=True,
)
user.set_password("rmltester")
user.save()
self.assertTrue(self.client.login(username=user.username, password="rmltester"))
response = self.client.get(reverse("event_print", kwargs={"pk": event.pk}))
self.assertEqual(response.status_code, 200)
# By the time we have a PDF it should be larger than the original by some margin
# RML hard fails if something doesn't work
self.assertGreater(len(response.content), len(self.markdown))
def test_nonetype(self):
html = markdown_filter(None)
self.assertIsNone(html)
def test_linebreaks(self):
html = markdown_filter(self.markdown)
self.assertIn("Itemized lists<br/>\nlook like", html)
def test_ra_big_power_without_power_mic_is_a_form_error(ra):
data = model_to_dict(ra)
data.update(big_power=True, power_mic=None, supervisor_consulted=True)
form = forms.EventRiskAssessmentForm(data=data, instance=ra)
assert not form.is_valid()
assert form.errors["power_mic"].as_data()[0].code == "power_mic_required"
def test_checkin_person_picker_only_for_event_mic(client, admin_user, basic_event, django_user_model):
other = django_user_model.objects.create_user(username="other", password="other", is_approved=True)
basic_event.mic = admin_user
basic_event.save()
url = reverse("event_checkin", kwargs={"pk": basic_event.pk})
client.force_login(admin_user)
assertContains(client.get(url), "ts-select")
client.force_login(other)
assertNotContains(client.get(url), "ts-select")