From 9075bcd9d6c06449c75e8bfcf59a44ee610d2c23 Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Mon, 13 Apr 2026 22:36:10 +0200 Subject: [PATCH 1/6] feat: add simple tests to codebase --- .circleci/config.yml | 2 +- discord/tests.py | 0 docs/testing.md | 226 ++++++++++++++++++++ hardware/tests.py | 3 - meals/tests.py | 0 pytest.ini | 7 + requirements.txt | 6 + setup.cfg | 6 + tests/__init__.py | 1 + tests/conftest.py | 83 +++++++ tests/factories.py | 150 +++++++++++++ baggage/tests.py => tests/flows/__init__.py | 0 tests/flows/test_hacker.py | 105 +++++++++ tests/flows/test_mentor.py | 72 +++++++ tests/flows/test_sponsor.py | 50 +++++ tests/flows/test_volunteer.py | 70 ++++++ 16 files changed, 777 insertions(+), 4 deletions(-) delete mode 100644 discord/tests.py create mode 100644 docs/testing.md delete mode 100644 hardware/tests.py delete mode 100644 meals/tests.py create mode 100644 pytest.ini create mode 100644 setup.cfg create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/factories.py rename baggage/tests.py => tests/flows/__init__.py (100%) create mode 100644 tests/flows/test_hacker.py create mode 100644 tests/flows/test_mentor.py create mode 100644 tests/flows/test_sponsor.py create mode 100644 tests/flows/test_volunteer.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 005a77356..0000a180f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -31,7 +31,7 @@ jobs: name: Running tests command: | . env/bin/activate - python manage.py test + pytest --cov - run: name: Linting code command: | diff --git a/discord/tests.py b/discord/tests.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 000000000..470dd9cf8 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,226 @@ +# Testing + +This project uses [pytest](https://pytest.org) with [pytest-django](https://pytest-django.readthedocs.io) and [factory-boy](https://factoryboy.readthedocs.io) for automated testing. Tests live in `tests/` and cover the four main application flows: hacker, volunteer, mentor, and sponsor. + +--- + +## Running the tests + +```bash +# Run all tests +pytest + +# With coverage report +pytest --cov + +# Run a single file +pytest tests/flows/test_hacker.py + +# Run a single test +pytest tests/flows/test_hacker.py::test_hacker_can_submit_application -v +``` + +Coverage is configured in `setup.cfg`. The report will fail if coverage across `applications`, `organizers`, and `user` drops below 60%. + +--- + +## Structure + +``` +tests/ +├── conftest.py # Shared fixtures (users, authenticated clients) +├── factories.py # factory-boy factories for creating test data +└── flows/ + ├── test_hacker.py # 8 tests covering the hacker application flow + ├── test_volunteer.py # 5 tests covering the volunteer application flow + ├── test_mentor.py # 5 tests covering the mentor application flow + └── test_sponsor.py # 3 tests covering the sponsor application flow +``` + +--- + +## How it works + +### Fixtures (`conftest.py`) + +`conftest.py` defines shared pytest fixtures available to every test file. + +`**use_locmem_email_backend` (autouse)** — runs automatically for every test. It overrides two Django settings that would otherwise break tests: + +- `EMAIL_BACKEND`: swaps SendGrid for Django's in-memory backend so views that send confirmation emails don't fail. +- `STATICFILES_STORAGE`: swaps whitenoise's manifest storage (which requires `collectstatic` to have been run) for a simple one that works without it. + +**User fixtures** — each creates a database user of the right type: + +```python +hacker_user # type=USR_HACKER +organizer_user # type=USR_ORGANIZER +volunteer_user # type=USR_VOLUNTEER +mentor_user # type=USR_MENTOR +sponsor_user # type=USR_SPONSOR +director_user # type=USR_ORGANIZER + is_director=True +``` + +**Client fixtures** — each returns `(client, user)` where the client is already logged in as that user: + +```python +hacker_client, organizer_client, volunteer_client, +mentor_client, sponsor_client, director_client +``` + +Use the tuple unpacking pattern in tests: + +```python +def test_something(hacker_client): + client, user = hacker_client + response = client.get(reverse("dashboard")) +``` + +### Factories (`factories.py`) + +Factories create realistic model instances without hitting external services. They use `factory.Sequence` for unique fields and `factory.Faker` for realistic fake data. + +**Important:** `UserFactory._create()` calls `user.set_password()` before saving. This is required because view mixins (`IsHackerMixin`, `DashboardMixin`, etc.) call `has_usable_password()` and redirect to the password-change page if it returns `False`. Django's default `create()` does not call `set_password()`, so the override is necessary. + + +| Factory | Model | Default status | +| ----------------------------- | ---------------------- | ------------------------------------ | +| `UserFactory` | `User` | — | +| `OrganizerUserFactory` | `User` | type=USR_ORGANIZER | +| `DirectorUserFactory` | `User` | type=USR_ORGANIZER, is_director=True | +| `HackerApplicationFactory` | `HackerApplication` | APP_PENDING | +| `VolunteerApplicationFactory` | `VolunteerApplication` | APP_PENDING | +| `MentorApplicationFactory` | `MentorApplication` | APP_PENDING | +| `SponsorApplicationFactory` | `SponsorApplication` | APP_CONFIRMED | + + +Override any field when creating an instance: + +```python +app = HackerApplicationFactory(user=user, status=APP_INVITED) +``` + +### Tests (`flows/`) + +Each test file covers one applicant type. Tests use `@pytest.mark.django_db` to get database access per test. The pattern is: + +1. Set up data (via fixtures or factories) +2. Make an HTTP request via `client.get()` or `client.post()` +3. Assert the response status code and the resulting database state + +--- + +## Key points to know + +### `origin` must match `cities.json` + +The `origin` field on application forms is validated against a list of cities. It must be in the format `"City, Province, Country"`: + +```python +"origin": "Barcelona, Barcelona, Spain" # correct +"origin": "Barcelona" # fails validation +``` + +### Cancel requires `APP_INVITED`, not `APP_PENDING` + +`BaseApplication.can_be_cancelled()` only returns `True` for `APP_INVITED`, `APP_CONFIRMED`, and `APP_LAST_REMINDER`. Testing cancellation with a PENDING application will fail silently (the view will redirect but the status won't change): + +```python +app = HackerApplicationFactory(user=user, status=APP_INVITED) # correct +app = HackerApplicationFactory(user=user, status=APP_PENDING) # can't be cancelled +``` + +### `ConfirmApplication` is GET-only + +The confirm view (`/application//confirm/`) uses `client.get()`, not `client.post()`. Confirming a PENDING application raises a `ValidationError` inside the model, which the view catches and converts to a 404. + +### Organizer vote uses integer PK, not UUID + +`ReviewApplicationView.post()` looks up the application with `HackerApplication.objects.get(pk=request.POST.get("app_id"))`. Pass the integer primary key as a string: + +```python +data={"app_id": str(app.pk), ...} # correct +data={"app_id": str(app.uuid), ...} # wrong — lookup will fail +``` + +### Mentor and sponsor lists require `is_director=True` + +`HaveMentorPermissionMixin` and `HaveSponsorPermissionMixin` require either a specific permission or `is_director=True`. A plain `OrganizerUserFactory` user will get a 302 redirect. Use `director_client`: + +```python +def test_organizer_can_view_mentor_list(director_client): # correct +def test_organizer_can_view_mentor_list(organizer_client): # 302, not 200 +``` + +### Sponsor submission uses a token URL, not the dashboard + +Sponsors apply via a unique invite URL (`/sponsor///`), not by logging in. The token comes from the `user.models.Token` model (not Django's password reset). Test it by constructing the URL directly: + +```python +token_obj = Token.objects.create(user=sponsor_user) +uid = urlsafe_base64_encode(force_bytes(sponsor_user.pk)) +url = f"/sponsor/{uid}/{token_obj.uuid_str()}/" +client.post(url, data=VALID_SPONSOR_FORM) +``` + +The view renders `sponsor_submitted.html` on success (status 200), not a redirect. + +--- + +## Adding a new test + +### Adding a test to an existing file + +Open the relevant file in `tests/flows/` and add a function: + +```python +@pytest.mark.django_db +def test_hacker_cannot_edit_after_review(hacker_client): + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("application")) + # invited hackers should not see the edit form + assert response.status_code == 302 +``` + +Use `@pytest.mark.django_db` on every test that touches the database. Use the fixtures from `conftest.py` as parameters — pytest injects them automatically. + +### Adding a test for a new applicant type + +1. Add a `UserFactory` subclass in `tests/factories.py` with the correct `type` value. +2. Add an `ApplicationFactory` subclass with all required fields (run the form in a browser or read the model to find required fields). +3. Add user and client fixtures to `tests/conftest.py` following the existing pattern. +4. Create `tests/flows/test_.py` and write your tests. + +### Adding a factory for a new model + +```python +class MyModelFactory(factory.django.DjangoModelFactory): + class Meta: + model = MyModel + + # Use factory.Sequence for fields that must be unique + name = factory.Sequence(lambda n: f"Name {n}") + + # Use factory.Faker for realistic fake data + description = factory.Faker("text", max_nb_chars=200) + + # Use factory.SubFactory to link related models + user = factory.SubFactory(UserFactory) + + # Hard-code constants where variation isn't needed + status = APP_PENDING +``` + +--- + +## CI + +Tests run automatically on CircleCI on every push. The CI config is at `.circleci/config.yml`. It runs: + +```bash +pytest --cov # runs tests and generates coverage +flake8 # lints the codebase +``` + +Both must pass for a build to go green. \ No newline at end of file diff --git a/hardware/tests.py b/hardware/tests.py deleted file mode 100644 index 2cef6ba97..000000000 --- a/hardware/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -# TODO -# from django.test import TestCase -# Create your tests here. diff --git a/meals/tests.py b/meals/tests.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..88e7640ea --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +DJANGO_SETTINGS_MODULE = app.settings +testpaths = tests +python_files = test_*.py +filterwarnings = + ignore::django.utils.deprecation.RemovedInDjango40Warning + ignore:Use '__' to separate path components:DeprecationWarning diff --git a/requirements.txt b/requirements.txt index 43d673b1b..d946bec2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,3 +58,9 @@ whitenoise==5.3.0 xlrd==1.2.0 xlwt==1.3.0 slack-sdk==3.15.2 +pytest==7.4.3 +pytest-django==4.7.0 +factory-boy==3.3.0 +faker==20.1.0 +coverage==7.3.2 +pytest-cov==4.1.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..000f3e702 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,6 @@ +[coverage:run] +source = applications,organizers,user +omit = */migrations/*, */tests/* + +[coverage:report] +fail_under = 60 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e878bfc80 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Don't delete this file, pytest needs it to find the source of tests hehe \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..1aad7e3ec --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,83 @@ +import pytest + +from tests.factories import ( + DirectorUserFactory, + MentorUserFactory, + OrganizerUserFactory, + SponsorUserFactory, + UserFactory, + VolunteerUserFactory, +) + + +@pytest.fixture(autouse=True) +def use_locmem_email_backend(settings): + """Override email backend so confirm views don't attempt to hit SendGrid.""" + settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + settings.STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" + + +@pytest.fixture +def hacker_user(db): + return UserFactory() + + +@pytest.fixture +def organizer_user(db): + return OrganizerUserFactory() + + +@pytest.fixture +def volunteer_user(db): + return VolunteerUserFactory() + + +@pytest.fixture +def mentor_user(db): + return MentorUserFactory() + + +@pytest.fixture +def sponsor_user(db): + return SponsorUserFactory() + + +@pytest.fixture +def hacker_client(client, hacker_user): + client.force_login(hacker_user) + return client, hacker_user + + +@pytest.fixture +def organizer_client(client, organizer_user): + client.force_login(organizer_user) + return client, organizer_user + + +@pytest.fixture +def volunteer_client(client, volunteer_user): + client.force_login(volunteer_user) + return client, volunteer_user + + +@pytest.fixture +def mentor_client(client, mentor_user): + client.force_login(mentor_user) + return client, mentor_user + + +@pytest.fixture +def sponsor_client(client, sponsor_user): + client.force_login(sponsor_user) + return client, sponsor_user + + +@pytest.fixture +def director_user(db): + return DirectorUserFactory() + + +@pytest.fixture +def director_client(client, director_user): + client.force_login(director_user) + return client, director_user diff --git a/tests/factories.py b/tests/factories.py new file mode 100644 index 000000000..0249621b6 --- /dev/null +++ b/tests/factories.py @@ -0,0 +1,150 @@ +import factory +from django.contrib.auth import get_user_model + +from applications.models import APP_CONFIRMED, APP_PENDING +from applications.models.hacker import HackerApplication +from applications.models.mentor import MentorApplication +from applications.models.sponsor import SponsorApplication +from applications.models.volunteer import VolunteerApplication +from user.models import ( + USR_HACKER, + USR_MENTOR, + USR_ORGANIZER, + USR_SPONSOR, + USR_VOLUNTEER, +) + +User = get_user_model() + + +class UserFactory(factory.django.DjangoModelFactory): + class Meta: + model = User + + email = factory.Sequence(lambda n: f"hacker{n}@example.com") + name = factory.Faker("name") + type = USR_HACKER + email_verified = True + is_active = True + + @classmethod + def _create(cls, model_class, *args, **kwargs): + # set_password() is required — views check has_usable_password() + user = model_class(*args, **kwargs) + user.set_password("testpass123") + user.save() + return user + + +class OrganizerUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"organizer{n}@example.com") + type = USR_ORGANIZER + + +class DirectorUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"director{n}@example.com") + type = USR_ORGANIZER + is_director = True + + +class VolunteerUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"volunteer{n}@example.com") + type = USR_VOLUNTEER + + +class MentorUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"mentor{n}@example.com") + type = USR_MENTOR + + +class SponsorUserFactory(UserFactory): + email = factory.Sequence(lambda n: f"sponsor{n}@example.com") + type = USR_SPONSOR + + +class HackerApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = HackerApplication + + user = factory.SubFactory(UserFactory) + status = APP_PENDING + origin = "Barcelona, Spain" + description = factory.Faker("text", max_nb_chars=200) + university = factory.Faker("company") + degree = "Computer Science" + kind_studies = "BACHELOR" + graduation_year = 2026 + tshirt_size = "M" + diet = "None" + phone_number = "+34600000000" + gender = "NA" + under_age = False + first_timer = True + lennyface = "( ͡° ͜ʖ ͡°)" + online = False + + +class VolunteerApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = VolunteerApplication + + user = factory.SubFactory(VolunteerUserFactory) + status = APP_PENDING + origin = "Barcelona, Spain" + gender = "NA" + tshirt_size = "M" + diet = "None" + under_age = False + first_timer = True + lennyface = "( ͡° ͜ʖ ͡°)" + studies_and_course = "Computer Science" + quality = "Teamwork" + weakness = "Perfectionism" + cool_skill = "Python" + volunteer_motivation = "I want to help hackers." + attendance = "1" + languages = "English" + night_shifts = "No" + first_time_volunteer = True + hear_about_us = "Posters" + + +class MentorApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = MentorApplication + + user = factory.SubFactory(MentorUserFactory) + status = APP_PENDING + origin = "Barcelona, Spain" + gender = "NA" + tshirt_size = "M" + diet = "None" + under_age = False + first_timer = True + lennyface = "( ͡° ͜ʖ ͡°)" + english_level = 3 + attendance = "1" + online = False + fluent = "Python, JavaScript" + experience = "5 years of software development" + why_mentor = "I want to share my knowledge with students." + participated = "HackUPC 2023" + study_work = True + degree = "Computer Science" + graduation_year = 2026 + first_time_mentor = True + + +class SponsorApplicationFactory(factory.django.DjangoModelFactory): + class Meta: + model = SponsorApplication + + user = factory.SubFactory(SponsorUserFactory) + status = APP_CONFIRMED # sponsors default to CONFIRMED, not PENDING + name = factory.Sequence(lambda n: f"Sponsor Corp {n}") + email = factory.Faker("email") + phone_number = "+34600000000" + tshirt_size = "M" + diet = "None" + position = "Engineer" + attendance = "1" diff --git a/baggage/tests.py b/tests/flows/__init__.py similarity index 100% rename from baggage/tests.py rename to tests/flows/__init__.py diff --git a/tests/flows/test_hacker.py b/tests/flows/test_hacker.py new file mode 100644 index 000000000..987ed21d4 --- /dev/null +++ b/tests/flows/test_hacker.py @@ -0,0 +1,105 @@ +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse + +from applications.models import APP_CANCELLED, APP_CONFIRMED, APP_INVITED, APP_PENDING +from applications.models.hacker import HackerApplication +from organizers.models import Vote +from tests.factories import HackerApplicationFactory + +VALID_HACKER_FORM = { + "phone_number": "+34600000000", + "kind_studies": "BACHELOR", + "under_age": "False", + "terms_and_conditions": True, + "diet": "None", + "tshirt_size": "M", + "origin": "Barcelona, Barcelona, Spain", + "description": "I want to build things at a hackathon.", + "graduation_year": "2026", + "gender": "NA", + "first_timer": True, + "lennyface": "( ͡° ͜ʖ ͡°)", + "online": False, + "university": "Universitat Politècnica de Catalunya", + "degree": "Computer Science", + "discover": "3", +} + + +@pytest.mark.django_db +def test_unauthenticated_redirected_from_dashboard(client): + response = client.get(reverse("dashboard")) + assert response.status_code == 302 + assert "/user/login/" in response["Location"] + + +@pytest.mark.django_db +def test_hacker_can_view_dashboard(hacker_client): + client, user = hacker_client + response = client.get(reverse("dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_hacker_can_submit_application(hacker_client): + client, user = hacker_client + resume = SimpleUploadedFile("cv.pdf", b"pdf content", content_type="application/pdf") + data = {**VALID_HACKER_FORM, "resume": resume} + response = client.post(reverse("dashboard"), data=data) + assert response.status_code == 302 + assert HackerApplication.objects.filter(user=user, status=APP_PENDING).exists() + + +@pytest.mark.django_db +def test_hacker_cannot_submit_duplicate(hacker_client): + client, user = hacker_client + HackerApplicationFactory(user=user) + resume = SimpleUploadedFile("cv.pdf", b"pdf content", content_type="application/pdf") + data = {**VALID_HACKER_FORM, "resume": resume} + client.post(reverse("dashboard"), data=data) + # OneToOneField constraint means there is always exactly one application per user + assert HackerApplication.objects.filter(user=user).count() == 1 + + +@pytest.mark.django_db +def test_hacker_can_cancel_invited(hacker_client): + # APP_PENDING cannot be cancelled — can_be_cancelled() requires INVITED/CONFIRMED/LAST_REMINDER + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_INVITED) + response = client.post(reverse("cancel_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CANCELLED + + +@pytest.mark.django_db +def test_invited_hacker_can_confirm(hacker_client): + # ConfirmApplication is GET-only + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CONFIRMED + + +@pytest.mark.django_db +def test_pending_hacker_cannot_confirm(hacker_client): + # confirm() raises ValidationError for PENDING status → view raises Http404 + client, user = hacker_client + app = HackerApplicationFactory(user=user, status=APP_PENDING) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_organizer_can_vote_on_application(organizer_client, db): + client, organizer = organizer_client + app = HackerApplicationFactory() + response = client.post( + reverse("review_detail", kwargs={"id": app.uuid_str}), + data={"app_id": str(app.pk), "tech_rat": "3", "pers_rat": "4"}, + ) + assert response.status_code == 302 + assert Vote.objects.filter(application=app, user=organizer).count() == 1 diff --git a/tests/flows/test_mentor.py b/tests/flows/test_mentor.py new file mode 100644 index 000000000..34b246bdb --- /dev/null +++ b/tests/flows/test_mentor.py @@ -0,0 +1,72 @@ +import pytest +from django.urls import reverse + +from applications.models import APP_CANCELLED, APP_CONFIRMED, APP_INVITED, APP_PENDING +from applications.models.mentor import MentorApplication +from tests.factories import MentorApplicationFactory + +VALID_MENTOR_FORM = { + "gender": "NA", + "under_age": "False", + "study_work": "True", + "english_level": "3", + "attendance": ["1"], + "tshirt_size": "M", + "diet": "None", + "origin": "Barcelona, Barcelona, Spain", + "linkedin": "https://www.linkedin.com/in/testmentor", + "fluent": "Python, JavaScript", + "experience": "5 years of software development.", + "why_mentor": "I want to share my knowledge with students.", + "participated": "HackUPC 2023", + "terms_and_conditions": True, + "degree": "Computer Science", + "graduation_year": "2026", + "first_timer": True, + "lennyface": "( ͡° ͜ʖ ͡°)", + "online": False, +} + + +@pytest.mark.django_db +def test_mentor_can_view_dashboard(mentor_client): + client, user = mentor_client + response = client.get(reverse("dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_mentor_can_submit_application(mentor_client): + client, user = mentor_client + response = client.post(reverse("dashboard"), data=VALID_MENTOR_FORM) + if response.status_code != 302: + print(response.context['form'].errors) + assert response.status_code == 302 + assert MentorApplication.objects.filter(user=user, status=APP_PENDING).exists() + + +@pytest.mark.django_db +def test_mentor_can_cancel_invited(mentor_client): + client, user = mentor_client + app = MentorApplicationFactory(user=user, status=APP_INVITED) + response = client.post(reverse("cancel_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CANCELLED + + +@pytest.mark.django_db +def test_invited_mentor_can_confirm(mentor_client): + client, user = mentor_client + app = MentorApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CONFIRMED + + +@pytest.mark.django_db +def test_organizer_can_view_mentor_list(director_client): + client, _ = director_client + response = client.get(reverse("mentor_list")) + assert response.status_code == 200 diff --git a/tests/flows/test_sponsor.py b/tests/flows/test_sponsor.py new file mode 100644 index 000000000..6f3c8c396 --- /dev/null +++ b/tests/flows/test_sponsor.py @@ -0,0 +1,50 @@ +import pytest +from django.test import Client +from django.urls import reverse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + +from applications.models.sponsor import SponsorApplication +from user.models import Token +from tests.factories import SponsorUserFactory + +VALID_SPONSOR_FORM = { + "name": "Jane Doe", + "email": "jane.doe@techcorp.com", + "attendance": ["1"], + "diet": "None", + "tshirt_size": "M", + "phone_number": "+34600000000", + "position": "Software Engineer", + "terms_and_conditions": True, +} + + +@pytest.mark.django_db +def test_sponsor_can_view_dashboard(sponsor_client): + client, user = sponsor_client + response = client.get(reverse("sponsor_dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_sponsor_can_submit_application(db): + sponsor_user = SponsorUserFactory() + token_obj = Token.objects.create(user=sponsor_user) + uid = urlsafe_base64_encode(force_bytes(sponsor_user.pk)) + token = token_obj.uuid_str() + url = f"/sponsor/{uid}/{token}/" + client = Client() + response = client.post(url, data=VALID_SPONSOR_FORM) + if response.status_code != 200: + print(response.context['form'].errors) + # View renders sponsor_submitted.html on success (200, not 302) + assert response.status_code == 200 + assert SponsorApplication.objects.count() == 1 + + +@pytest.mark.django_db +def test_organizer_can_view_sponsor_list(director_client): + client, _ = director_client + response = client.get(reverse("sponsor_list")) + assert response.status_code == 200 diff --git a/tests/flows/test_volunteer.py b/tests/flows/test_volunteer.py new file mode 100644 index 000000000..864926df7 --- /dev/null +++ b/tests/flows/test_volunteer.py @@ -0,0 +1,70 @@ +import pytest +from django.urls import reverse + +from applications.models import APP_CANCELLED, APP_CONFIRMED, APP_INVITED, APP_PENDING +from applications.models.volunteer import VolunteerApplication +from tests.factories import VolunteerApplicationFactory + +VALID_VOLUNTEER_FORM = { + "gender": "NA", + "under_age": "False", + "studies_and_course": "Computer Science", + "night_shifts": "No", + "first_time_volunteer": "True", + "diet": "None", + "tshirt_size": "M", + "origin": "Barcelona, Barcelona, Spain", + "hear_about_us": "Posters", + "terms_and_conditions": True, + "attendance": ["1"], + "languages": ["English"], + "quality": "Team player", + "weakness": "Perfectionist", + "cool_skill": "Python", + "volunteer_motivation": "I want to help hackers succeed.", + "graduation_year": "2026", +} + + +@pytest.mark.django_db +def test_volunteer_can_view_dashboard(volunteer_client): + client, user = volunteer_client + response = client.get(reverse("dashboard")) + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_volunteer_can_submit_application(volunteer_client): + client, user = volunteer_client + response = client.post(reverse("dashboard"), data=VALID_VOLUNTEER_FORM) + if response.status_code != 302: + print(response.context['form'].errors) + assert response.status_code == 302 + assert VolunteerApplication.objects.filter(user=user, status=APP_PENDING).exists() + + +@pytest.mark.django_db +def test_volunteer_can_cancel_invited(volunteer_client): + client, user = volunteer_client + app = VolunteerApplicationFactory(user=user, status=APP_INVITED) + response = client.post(reverse("cancel_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CANCELLED + + +@pytest.mark.django_db +def test_invited_volunteer_can_confirm(volunteer_client): + client, user = volunteer_client + app = VolunteerApplicationFactory(user=user, status=APP_INVITED) + response = client.get(reverse("confirm_app", kwargs={"id": app.uuid_str})) + assert response.status_code == 302 + app.refresh_from_db() + assert app.status == APP_CONFIRMED + + +@pytest.mark.django_db +def test_organizer_can_view_volunteer_list(organizer_client): + client, _ = organizer_client + response = client.get(reverse("volunteer_list")) + assert response.status_code == 200 From 315fb90fce05025259cc75a21a78be79d6dc2c8d Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Mon, 24 Aug 2026 10:49:34 +0200 Subject: [PATCH 2/6] test: cover auth flows to satisfy coverage gate The 60% coverage gate was already failing on the adding-tests base branch (55.56%). Adds flow tests for signup, login, logout, password reset, email activation, and verification views, lifting total coverage to 60.77%. Co-Authored-By: Claude Fable 5 --- tests/flows/test_auth.py | 175 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 tests/flows/test_auth.py diff --git a/tests/flows/test_auth.py b/tests/flows/test_auth.py new file mode 100644 index 000000000..7bbc49c40 --- /dev/null +++ b/tests/flows/test_auth.py @@ -0,0 +1,175 @@ +import pytest +from django.contrib.auth import get_user_model +from django.core import mail +from django.urls import reverse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + +from tests.factories import UserFactory +from user.tokens import account_activation_token, password_reset_token + +User = get_user_model() + +VALID_SIGNUP_FORM = { + "name": "Gerard Madrid", + "email": "newuser@example.com", + "password": "S3curePass!x", + "password2": "S3curePass!x", + "terms_and_conditions": True, +} + + +@pytest.mark.django_db +def test_signup_creates_user_and_logs_in(client): + response = client.post(reverse("account_signup"), data=VALID_SIGNUP_FORM) + + assert response.status_code == 302 + assert User.objects.filter(email="newuser@example.com").count() == 1 + assert response.wsgi_request.user.is_authenticated + + +@pytest.mark.django_db +def test_signup_rejects_duplicate_email(client): + UserFactory(email="newuser@example.com") + + response = client.post(reverse("account_signup"), data=VALID_SIGNUP_FORM) + + assert response.status_code == 200 + assert User.objects.filter(email="newuser@example.com").count() == 1 + + +@pytest.mark.django_db +def test_signup_rejects_mismatched_passwords(client): + response = client.post(reverse("account_signup"), data={**VALID_SIGNUP_FORM, "password2": "Different1!"}) + + assert response.status_code == 200 + assert User.objects.filter(email="newuser@example.com").count() == 0 + + +@pytest.mark.django_db +def test_login_with_valid_credentials(client): + UserFactory(email="hacker@example.com") + + response = client.post(reverse("account_login"), data={"email": "hacker@example.com", "password": "testpass123"}) + + assert response.status_code == 302 + assert response.wsgi_request.user.is_authenticated + + +@pytest.mark.django_db +def test_login_with_wrong_password_shows_error(client): + UserFactory(email="hacker@example.com") + + response = client.post(reverse("account_login"), data={"email": "hacker@example.com", "password": "wrongpass1!"}) + + assert response.status_code == 200 + assert b"Incorrect username or password" in response.content + + +@pytest.mark.django_db +def test_login_succeeds_after_failed_attempt(client): + UserFactory(email="hacker@example.com") + client.post(reverse("account_login"), data={"email": "hacker@example.com", "password": "wrongpass1!"}) + + response = client.post(reverse("account_login"), data={"email": "hacker@example.com", "password": "testpass123"}) + + assert response.status_code == 302 + assert response.wsgi_request.user.is_authenticated + + +@pytest.mark.django_db +def test_logout_deauthenticates(hacker_client): + client, user = hacker_client + + response = client.get(reverse("account_logout")) + + assert response.status_code == 302 + assert not response.wsgi_request.user.is_authenticated + + +@pytest.mark.django_db +def test_password_reset_sends_email(client): + UserFactory(email="hacker@example.com") + + response = client.post(reverse("password_reset"), data={"email": "hacker@example.com"}) + + assert response.status_code == 302 + assert len(mail.outbox) == 1 + + +@pytest.mark.django_db +def test_password_reset_rejects_unknown_email(client): + response = client.post(reverse("password_reset"), data={"email": "nobody@example.com"}) + + assert response.status_code == 200 + assert len(mail.outbox) == 0 + + +@pytest.mark.django_db +def test_password_reset_confirm_sets_new_password(client): + user = UserFactory(email="hacker@example.com") + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = password_reset_token.make_token(user) + + response = client.post( + reverse("password_reset_confirm", kwargs={"uid": uid, "token": token}), + data={"new_password1": "Fr3shPass!x", "new_password2": "Fr3shPass!x"}, + ) + + user.refresh_from_db() + assert response.status_code == 302 + assert user.check_password("Fr3shPass!x") + + +@pytest.mark.django_db +def test_password_reset_confirm_rejects_invalid_token(client): + user = UserFactory(email="hacker@example.com") + uid = urlsafe_base64_encode(force_bytes(user.pk)) + + response = client.get(reverse("password_reset_confirm", kwargs={"uid": uid, "token": "123-abc"})) + + assert response.status_code == 200 + assert response.context["validlink"] is False + + +@pytest.mark.django_db +def test_activate_verifies_email(client): + user = UserFactory(email="hacker@example.com", email_verified=False) + uid = urlsafe_base64_encode(force_bytes(user.pk)) + token = account_activation_token.make_token(user) + + response = client.get(reverse("activate", kwargs={"uid": uid, "token": token})) + + user.refresh_from_db() + assert response.status_code == 302 + assert user.email_verified + + +@pytest.mark.django_db +def test_activate_with_unknown_user_redirects(client): + uid = urlsafe_base64_encode(force_bytes(99999)) + + response = client.get(reverse("activate", kwargs={"uid": uid, "token": "123-abc"})) + + assert response.status_code == 302 + + +@pytest.mark.django_db +def test_send_email_verification_for_unverified_user(client): + user = UserFactory(email="hacker@example.com", email_verified=False) + client.force_login(user) + mail.outbox.clear() + + response = client.get(reverse("send_email_verification")) + + assert response.status_code == 302 + assert len(mail.outbox) == 1 + + +@pytest.mark.django_db +def test_verify_email_required_redirects_verified_user(hacker_client): + client, user = hacker_client + + response = client.get(reverse("verify_email_required")) + + assert response.status_code == 302 From bb1c71ac7b9997d71ead9ec74df74171f8385d42 Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Mon, 24 Aug 2026 11:01:13 +0200 Subject: [PATCH 3/6] lint: add missing newline at end of tests/__init__.py Co-Authored-By: Claude Fable 5 --- tests/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/__init__.py b/tests/__init__.py index e878bfc80..613fe8b63 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -# Don't delete this file, pytest needs it to find the source of tests hehe \ No newline at end of file +# Don't delete this file, pytest needs it to find the source of tests hehe From bd7fe538bd336629aebfb8a4f4cffbfc9ac85f68 Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Mon, 24 Aug 2026 11:12:23 +0200 Subject: [PATCH 4/6] test: cover organizer review and list flows Raises coverage from 60.6% to 68.3%: review voting (show next pending, skip, comment, mark dubious), director actions (invite, confirm, waitlist, batch invite, waitlist-all), all organizer list views with permission checks, and user profile. Co-Authored-By: Claude Fable 5 --- tests/flows/test_organizer_lists.py | 174 +++++++++++++++++++++++++++ tests/flows/test_organizer_review.py | 164 +++++++++++++++++++++++++ tests/flows/test_profile.py | 22 ++++ 3 files changed, 360 insertions(+) create mode 100644 tests/flows/test_organizer_lists.py create mode 100644 tests/flows/test_organizer_review.py create mode 100644 tests/flows/test_profile.py diff --git a/tests/flows/test_organizer_lists.py b/tests/flows/test_organizer_lists.py new file mode 100644 index 000000000..05384e917 --- /dev/null +++ b/tests/flows/test_organizer_lists.py @@ -0,0 +1,174 @@ +import pytest +from django.urls import reverse + +from applications.models import APP_BLACKLISTED, APP_DUBIOUS, APP_INVITED, APP_PENDING, APP_REJECTED +from applications.models.hacker import HackerApplication +from tests.factories import ( + HackerApplicationFactory, + MentorApplicationFactory, + SponsorApplicationFactory, + VolunteerApplicationFactory, +) + + +@pytest.fixture +def director_client(client, director_user): + client.force_login(director_user) + return client, director_user + + +@pytest.mark.django_db +def test_organizer_can_view_application_list(organizer_client): + client, organizer = organizer_client + app = HackerApplicationFactory() + + response = client.get(reverse("app_list")) + + assert response.status_code == 200 + assert app.user.email in response.context["emails"] + + +@pytest.mark.django_db +def test_hacker_cannot_view_application_list(hacker_client): + client, hacker = hacker_client + + response = client.get(reverse("app_list")) + + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_organizer_can_view_volunteer_list(organizer_client): + client, organizer = organizer_client + VolunteerApplicationFactory() + + response = client.get(reverse("volunteer_list")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_invite_list(director_client): + client, director = director_client + HackerApplicationFactory(status=APP_PENDING) + HackerApplicationFactory(status=APP_INVITED) + + response = client.get(reverse("invite_list")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_organizer_cannot_view_invite_list(organizer_client): + client, organizer = organizer_client + + response = client.get(reverse("invite_list")) + + assert response.status_code == 403 + + +@pytest.mark.django_db +def test_director_can_batch_invite(director_client): + client, director = director_client + app = HackerApplicationFactory(status=APP_PENDING) + + response = client.post(reverse("invite_list"), data={"selected": [str(app.pk)]}) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_INVITED + + +@pytest.mark.django_db +def test_director_can_waitlist_all_pending(director_client): + client, director = director_client + app = HackerApplicationFactory(status=APP_PENDING) + + response = client.post(reverse("waitlisted")) + + app.refresh_from_db() + assert response.status_code == 200 + assert app.status == APP_REJECTED + + +@pytest.mark.django_db +def test_director_can_view_dubious_list(director_client): + client, director = director_client + HackerApplication.objects.filter(pk=HackerApplicationFactory().pk).update(status=APP_DUBIOUS) + + response = client.get(reverse("dubious")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_blacklist(director_client): + client, director = director_client + HackerApplication.objects.filter(pk=HackerApplicationFactory().pk).update(status=APP_BLACKLISTED) + + response = client.get(reverse("blacklist")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_mentor_list(director_client): + client, director = director_client + MentorApplicationFactory() + + response = client.get(reverse("mentor_list")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_sponsor_list(director_client): + client, director = director_client + SponsorApplicationFactory() + + response = client.get(reverse("sponsor_list")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_sponsor_user_list(director_client): + client, director = director_client + + response = client.get(reverse("sponsor_user_list")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_mentor_detail(director_client): + client, director = director_client + app = MentorApplicationFactory() + + response = client.get(reverse("mentor_detail", kwargs={"id": app.uuid_str})) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_view_volunteer_detail(director_client): + client, director = director_client + app = VolunteerApplicationFactory() + + response = client.get(reverse("volunteer_detail", kwargs={"id": app.uuid_str})) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_director_can_invite_volunteer(director_client): + client, director = director_client + app = VolunteerApplicationFactory() + + response = client.post( + reverse("volunteer_detail", kwargs={"id": app.uuid_str}), data={"app_id": str(app.pk), "invite": "true"} + ) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_INVITED diff --git a/tests/flows/test_organizer_review.py b/tests/flows/test_organizer_review.py new file mode 100644 index 000000000..d7c024409 --- /dev/null +++ b/tests/flows/test_organizer_review.py @@ -0,0 +1,164 @@ +from datetime import timedelta + +import pytest +from django.core import mail +from django.urls import reverse +from django.utils import timezone + +from applications.models import APP_CONFIRMED, APP_DUBIOUS, APP_INVITED, APP_REJECTED +from organizers.models import ApplicationComment, Vote +from tests.factories import HackerApplicationFactory + + +def reviewable_application(**kwargs): + return HackerApplicationFactory(submission_date=timezone.now() - timedelta(hours=3), **kwargs) + + +@pytest.fixture +def director_client(client, director_user): + client.force_login(director_user) + return client, director_user + + +@pytest.mark.django_db +def test_review_shows_oldest_pending_application(organizer_client): + client, organizer = organizer_client + app = reviewable_application() + + response = client.get(reverse("review")) + + assert response.status_code == 200 + assert response.context["app"].pk == app.pk + + +@pytest.mark.django_db +def test_review_shows_nothing_when_all_voted(organizer_client): + client, organizer = organizer_client + app = reviewable_application() + Vote.objects.create(application=app, user=organizer) + + response = client.get(reverse("review")) + + assert response.status_code == 200 + assert response.context["app"] is None + + +@pytest.mark.django_db +def test_organizer_can_skip_application(organizer_client): + client, organizer = organizer_client + app = reviewable_application() + + response = client.post(reverse("review"), data={"app_id": str(app.pk), "skip": "true"}) + + assert response.status_code == 302 + assert Vote.objects.filter(application=app, user=organizer, tech=None, personal=None).count() == 1 + + +@pytest.mark.django_db +def test_organizer_can_comment_from_review(organizer_client): + client, organizer = organizer_client + app = reviewable_application() + + response = client.post( + reverse("review"), data={"app_id": str(app.pk), "add_comment": "true", "comment_text": "Solid application"} + ) + + assert response.status_code == 302 + assert ApplicationComment.objects.filter(hacker=app, author=organizer, text="Solid application").count() == 1 + + +@pytest.mark.django_db +def test_organizer_can_mark_application_dubious(organizer_client): + client, organizer = organizer_client + app = reviewable_application() + + response = client.post( + reverse("review"), + data={ + "app_id": str(app.pk), + "set_dubious": "true", + "dubious_type": "Other", + "dubious_comment_text": "Suspicious description", + }, + ) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_DUBIOUS + + +@pytest.mark.django_db +def test_organizer_can_view_application_detail(organizer_client): + client, organizer = organizer_client + app = HackerApplicationFactory() + + response = client.get(reverse("app_detail", kwargs={"id": app.uuid_str})) + + assert response.status_code == 200 + assert response.context["app"].pk == app.pk + + +@pytest.mark.django_db +def test_application_detail_unknown_id_returns_404(organizer_client): + client, organizer = organizer_client + + response = client.get(reverse("app_detail", kwargs={"id": "00000000000000000000000000000000"})) + + assert response.status_code == 404 + + +@pytest.mark.django_db +def test_director_can_invite_application(director_client): + client, director = director_client + app = HackerApplicationFactory() + + response = client.post( + reverse("app_detail", kwargs={"id": app.uuid_str}), data={"app_id": str(app.pk), "invite": "true"} + ) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_INVITED + assert len(mail.outbox) == 1 + + +@pytest.mark.django_db +def test_director_can_confirm_invited_application(director_client): + client, director = director_client + app = HackerApplicationFactory(status=APP_INVITED) + + response = client.post( + reverse("app_detail", kwargs={"id": app.uuid_str}), data={"app_id": str(app.pk), "confirm": "true"} + ) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_CONFIRMED + + +@pytest.mark.django_db +def test_director_can_waitlist_pending_application(director_client): + client, director = director_client + app = HackerApplicationFactory() + + response = client.post( + reverse("app_detail", kwargs={"id": app.uuid_str}), data={"app_id": str(app.pk), "waitlist": "true"} + ) + + app.refresh_from_db() + assert response.status_code == 302 + assert app.status == APP_REJECTED + + +@pytest.mark.django_db +def test_organizer_can_comment_on_application_detail(organizer_client): + client, organizer = organizer_client + app = HackerApplicationFactory() + + response = client.post( + reverse("app_detail", kwargs={"id": app.uuid_str}), + data={"app_id": str(app.pk), "add_comment": "true", "comment_text": "Reviewed manually"}, + ) + + assert response.status_code == 302 + assert ApplicationComment.objects.filter(hacker=app, author=organizer, text="Reviewed manually").count() == 1 diff --git a/tests/flows/test_profile.py b/tests/flows/test_profile.py new file mode 100644 index 000000000..f969a9f30 --- /dev/null +++ b/tests/flows/test_profile.py @@ -0,0 +1,22 @@ +import pytest +from django.urls import reverse + + +@pytest.mark.django_db +def test_hacker_can_view_profile(hacker_client): + client, user = hacker_client + + response = client.get(reverse("user_profile")) + + assert response.status_code == 200 + + +@pytest.mark.django_db +def test_hacker_can_update_name(hacker_client): + client, user = hacker_client + + response = client.post(reverse("user_profile"), data={"name": "Gerard Màdrid", "type": "H"}) + + user.refresh_from_db() + assert response.status_code == 200 + assert user.name == "Gerard Màdrid" From d3667a0fd557ce9b5aac6f69cf13e9c7f777bb0c Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Mon, 24 Aug 2026 10:40:36 +0200 Subject: [PATCH 5/6] feat: make all search accent and case insensitive Adds a bilateral unaccent lookup on CharField/TextField, backed by the postgres unaccent extension in prod and a Python-registered sqlite function in dev. All table search filters and admin search_fields now use field__unaccent__icontains, so queries match regardless of accents or case in either the query or the stored value. Also fixes two pre-existing broken admin searches: judging Room searched raw FKs (challenge, main_judge) and meals Eaten searched a nonexistent name field. Co-Authored-By: Claude Fable 5 --- applications/admin.py | 8 ++--- baggage/admin.py | 4 +-- baggage/tables.py | 24 ++++++------- checkin/admin.py | 8 +++-- checkin/tables.py | 6 ++-- discord/admin.py | 2 +- discord/tables.py | 8 ++--- hardware/tables.py | 4 +-- hardware/views/admin.py | 2 +- judging/admin.py | 8 ++--- meals/admin.py | 4 +-- meals/tables.py | 6 ++-- organizers/admin.py | 2 +- organizers/tables.py | 40 +++++++++++----------- reimbursement/admin.py | 2 +- reimbursement/tables.py | 12 +++---- teams/admin.py | 2 +- tests/test_unaccent_search.py | 33 ++++++++++++++++++ user/admin.py | 4 +-- user/apps.py | 2 ++ user/lookups.py | 27 +++++++++++++++ user/migrations/0020_unaccent_extension.py | 13 +++++++ 22 files changed, 149 insertions(+), 72 deletions(-) create mode 100644 tests/test_unaccent_search.py create mode 100644 user/lookups.py create mode 100644 user/migrations/0020_unaccent_extension.py diff --git a/applications/admin.py b/applications/admin.py index 99b3fdadb..a67abc3f6 100644 --- a/applications/admin.py +++ b/applications/admin.py @@ -15,8 +15,8 @@ class ApplicationAdmin(admin.ModelAdmin): list_filter = ('status', 'first_timer', 'reimb', 'graduation_year', 'university', 'origin', 'under_age', 'diet') list_per_page = 200 - search_fields = ('user__name', 'user__email', - 'description',) + search_fields = ('user__name__unaccent', 'user__email__unaccent', + 'description__unaccent',) ordering = ('submission_date',) date_hierarchy = 'submission_date' @@ -47,7 +47,7 @@ class OtherApplicationAdmin(admin.ModelAdmin): list_display = ('user', 'name', 'status', 'status_last_updated', 'diet') list_filter = ('status', 'under_age', 'diet') list_per_page = 200 - search_fields = ('user__name', 'user__email',) + search_fields = ('user__name__unaccent', 'user__email__unaccent',) ordering = ('submission_date',) date_hierarchy = 'submission_date' @@ -72,7 +72,7 @@ class SponsorApplicationAdmin(OtherApplicationAdmin): class DraftApplicationAdmin(admin.ModelAdmin): list_display = ('user', 'name') list_per_page = 200 - search_fields = ('user__name', 'user__email',) + search_fields = ('user__name__unaccent', 'user__email__unaccent',) ordering = ('user__name',) def name(self, obj): diff --git a/baggage/admin.py b/baggage/admin.py index 27ae8d3b7..d6a19b3d6 100644 --- a/baggage/admin.py +++ b/baggage/admin.py @@ -7,7 +7,7 @@ class BaggageRoomAdmin(admin.ModelAdmin): 'room', 'row', 'col', 'door_row', 'door_col' ) search_fields = ( - 'room', + 'room__unaccent', ) def get_actions(self, request): @@ -19,7 +19,7 @@ class BaggageListAdmin(admin.ModelAdmin): 'bid', 'owner', 'status', 'btype', 'color', 'description', 'special', 'time', 'updated' ) search_fields = ( - 'owner__email', 'owner__name', 'status', 'btype', 'color', 'description' + 'owner__email__unaccent', 'owner__name__unaccent', 'status__unaccent', 'btype__unaccent', 'color__unaccent', 'description__unaccent' ) list_filter = ( 'status', 'btype', 'color', 'special' diff --git a/baggage/tables.py b/baggage/tables.py index 0b657ebe9..7a63c75e5 100644 --- a/baggage/tables.py +++ b/baggage/tables.py @@ -21,9 +21,9 @@ class BaggageListFilter(django_filters.FilterSet): def search_filter(self, queryset, name, value): queryfilter = queryset.annotate(fullpos=Concat('room', 'row', 'col', output_field=CharField())) - return queryfilter.filter((Q(owner__email__icontains=value) | Q(owner__name__icontains=value) | - Q(status__icontains=value) | Q(btype__icontains=value) | Q(color__icontains=value) | - Q(description__icontains=value) | Q(fullpos__icontains=value))) + return queryfilter.filter((Q(owner__email__unaccent__icontains=value) | Q(owner__name__unaccent__icontains=value) | + Q(status__unaccent__icontains=value) | Q(btype__unaccent__icontains=value) | Q(color__unaccent__icontains=value) | + Q(description__unaccent__icontains=value) | Q(fullpos__unaccent__icontains=value))) def search_time(self, queryset, name, value): if name == 'time_from': @@ -39,15 +39,15 @@ class BaggageUsersFilter(django_filters.FilterSet): search = django_filters.CharFilter(method='search_filter', label='Search') def search_filter(self, queryset, name, value): - return queryset.filter(Q(hacker__user__email__icontains=value) | - Q(hacker__user__name__icontains=value) | - Q(volunteer__user__email__icontains=value) | - Q(volunteer__user__name__icontains=value) | - Q(mentor__user__email__icontains=value) | - Q(mentor__user__name__icontains=value) | - Q(sponsor__user__email__icontains=value) | - Q(sponsor__user__name__icontains=value) | - Q(qr_identifier__icontains=value)) + return queryset.filter(Q(hacker__user__email__unaccent__icontains=value) | + Q(hacker__user__name__unaccent__icontains=value) | + Q(volunteer__user__email__unaccent__icontains=value) | + Q(volunteer__user__name__unaccent__icontains=value) | + Q(mentor__user__email__unaccent__icontains=value) | + Q(mentor__user__name__unaccent__icontains=value) | + Q(sponsor__user__email__unaccent__icontains=value) | + Q(sponsor__user__name__unaccent__icontains=value) | + Q(qr_identifier__unaccent__icontains=value)) class Meta: model = CheckIn diff --git a/checkin/admin.py b/checkin/admin.py index f51a45554..2a8f081ac 100644 --- a/checkin/admin.py +++ b/checkin/admin.py @@ -9,9 +9,11 @@ class CheckinAdmin(admin.ModelAdmin): list_display = ( 'user', 'type', 'application', 'update_time' ) - search_fields = ('user__email', 'user__name', 'hacker__user__name', 'hacker__user__email', 'volunteer__user__name', - 'volunteer__user__email', 'mentor__user__name', 'mentor__user__email', 'sponsor__user__name', - 'sponsor__user__email') + search_fields = ('user__email__unaccent', 'user__name__unaccent', 'hacker__user__name__unaccent', + 'hacker__user__email__unaccent', 'volunteer__user__name__unaccent', + 'volunteer__user__email__unaccent', 'mentor__user__name__unaccent', + 'mentor__user__email__unaccent', 'sponsor__user__name__unaccent', + 'sponsor__user__email__unaccent') date_hierarchy = 'update_time' list_filter = ('user', ) actions = ['delete_selected', ] diff --git a/checkin/tables.py b/checkin/tables.py index a414ea2fd..db658679b 100644 --- a/checkin/tables.py +++ b/checkin/tables.py @@ -9,7 +9,7 @@ class ApplicationCheckinFilter(django_filters.FilterSet): search = django_filters.CharFilter(method='search_filter', label='Search') def search_filter(self, queryset, name, value): - return queryset.filter(Q(user__email__icontains=value) | Q(user__name__icontains=value) | + return queryset.filter(Q(user__email__unaccent__icontains=value) | Q(user__name__unaccent__icontains=value) | Q(uuid__icontains=value.replace('-', ''))) class Meta: @@ -34,8 +34,8 @@ class SponsorApplicationCheckinFilter(django_filters.FilterSet): search = django_filters.CharFilter(method='search_filter', label='Search') def search_filter(self, queryset, name, value): - return queryset.filter(Q(user__email__icontains=value) | Q(user__name__icontains=value) | - Q(name__icontains=value)) + return queryset.filter(Q(user__email__unaccent__icontains=value) | Q(user__name__unaccent__icontains=value) | + Q(name__unaccent__icontains=value)) class Meta: model = SponsorApplication diff --git a/discord/admin.py b/discord/admin.py index c437bec60..c4345cd07 100644 --- a/discord/admin.py +++ b/discord/admin.py @@ -7,7 +7,7 @@ class DiscordUserAdmin(admin.ModelAdmin): list_display = ( 'user', 'discord_id', 'checked_in' ) - search_fields = ('user__email', 'user__name') + search_fields = ('user__email__unaccent', 'user__name__unaccent') list_filter = ('user',) actions = ['delete_selected', ] diff --git a/discord/tables.py b/discord/tables.py index de7131fc2..44b29c2c9 100644 --- a/discord/tables.py +++ b/discord/tables.py @@ -24,10 +24,10 @@ class DiscordFilter(django_filters.FilterSet): search = django_filters.CharFilter(method='search_filter', label='Search') def search_filter(self, queryset, name, value): - return queryset.filter(Q(user__email__icontains=value) | - Q(user__name__icontains=value) | - Q(discord_username__icontains=value) | - Q(team_name__icontains=value)) + return queryset.filter(Q(user__email__unaccent__icontains=value) | + Q(user__name__unaccent__icontains=value) | + Q(discord_username__unaccent__icontains=value) | + Q(team_name__unaccent__icontains=value)) class Meta: model = DiscordUser diff --git a/hardware/tables.py b/hardware/tables.py index 5d7d26a9c..bb30bc137 100644 --- a/hardware/tables.py +++ b/hardware/tables.py @@ -32,7 +32,7 @@ def status_filter(self, queryset, name, value): return qs.distinct() def search_filter(self, queryset, name, value): - return queryset.filter(Q(item_type__name__icontains=value) | Q(user__name__icontains=value)) + return queryset.filter(Q(item_type__name__unaccent__icontains=value) | Q(user__name__unaccent__icontains=value)) class Meta: model = Request @@ -50,7 +50,7 @@ def status_filter(self, queryset, name, value): return queryset.get_active() def search_filter(self, queryset, name, value): - return queryset.filter(Q(item__item_type__name__icontains=value) | Q(user__name__icontains=value)) + return queryset.filter(Q(item__item_type__name__unaccent__icontains=value) | Q(user__name__unaccent__icontains=value)) class Meta: model = Borrowing diff --git a/hardware/views/admin.py b/hardware/views/admin.py index 5cccaa536..4093f14fa 100644 --- a/hardware/views/admin.py +++ b/hardware/views/admin.py @@ -179,7 +179,7 @@ def identify_hacker(self, request): Gets a list of suggestions based on the input (typeahead) """ checkins = CheckIn.objects.filter( - Q(hacker__user__name__icontains=request.POST['query']) | + Q(hacker__user__name__unaccent__icontains=request.POST['query']) | Q(hacker__user__email__startswith=request.POST['query']) | Q(qr_identifier=request.POST['query'])) diff --git a/judging/admin.py b/judging/admin.py index d95315007..8006bce94 100644 --- a/judging/admin.py +++ b/judging/admin.py @@ -5,19 +5,19 @@ class ProjectAdmin(admin.ModelAdmin): list_display = ('title', 'url', 'desired_prizes', 'description', 'university') - search_fields = ['title', 'url', 'university', 'desired_prizes', - 'submitter_first_name', 'submitter_last_name'] + search_fields = ['title__unaccent', 'url', 'university__unaccent', 'desired_prizes__unaccent', + 'submitter_first_name__unaccent', 'submitter_last_name__unaccent'] list_per_page = 100 class ChallengeAdmin(admin.ModelAdmin): list_display = ('name',) - search_fields = ['name'] + search_fields = ['name__unaccent'] class RoomAdmin(admin.ModelAdmin): list_display = ('name', 'challenge', 'main_judge') - search_fields = ['name', 'challenge', 'main_judge'] + search_fields = ['name__unaccent', 'challenge__name__unaccent', 'main_judge__name__unaccent'] class PresentationAdmin(admin.ModelAdmin): diff --git a/meals/admin.py b/meals/admin.py index ca1e52f07..cdf93369b 100644 --- a/meals/admin.py +++ b/meals/admin.py @@ -7,7 +7,7 @@ class MealsMealAdmin(admin.ModelAdmin): 'id', 'name', 'times', 'opened', 'starts', 'ends' ) search_fields = ( - 'name', + 'name__unaccent', ) def get_actions(self, request): @@ -19,7 +19,7 @@ class MealsEatenAdmin(admin.ModelAdmin): 'id', 'meal', 'user', 'time' ) search_fields = ( - 'name', 'user__name', 'user__email' + 'meal__name__unaccent', 'user__name__unaccent', 'user__email__unaccent' ) list_filter = ( 'meal', 'user' diff --git a/meals/tables.py b/meals/tables.py index 3622e0efa..5a104e566 100644 --- a/meals/tables.py +++ b/meals/tables.py @@ -11,7 +11,7 @@ class MealsListFilter(django_filters.FilterSet): kind = django_filters.ChoiceFilter(label='Type', choices=MEAL_TYPE, empty_label='Any') def search_filter(self, queryset, name, value): - return queryset.filter((Q(name__icontains=value) | Q(kind__icontains=value))) + return queryset.filter((Q(name__unaccent__icontains=value) | Q(kind__unaccent__icontains=value))) class Meta: model = Meal @@ -55,8 +55,8 @@ def search_filter(self, queryset, name, value): checkin = CheckIn.objects.get(qr_identifier=value) return queryset.filter(user=checkin.application.user) except CheckIn.DoesNotExist: - return queryset.filter(Q(meal__name__icontains=value) | - Q(user__name__icontains=value) | Q(user__email__icontains=value)) + return queryset.filter(Q(meal__name__unaccent__icontains=value) | + Q(user__name__unaccent__icontains=value) | Q(user__email__unaccent__icontains=value)) class Meta: model = Meal diff --git a/organizers/admin.py b/organizers/admin.py index 9daecb11d..05b7592c8 100644 --- a/organizers/admin.py +++ b/organizers/admin.py @@ -15,7 +15,7 @@ class VoteAdmin(admin.ModelAdmin): list_display = ('application', 'user', 'tech', 'personal', 'calculated_vote') list_per_page = 200 list_filter = ('user', 'application') - search_fields = ('application__user__name', 'application__user__email', 'user__name', 'user__email') + search_fields = ('application__user__name__unaccent', 'application__user__email__unaccent', 'user__name__unaccent', 'user__email__unaccent') actions = ['delete_selected', ] diff --git a/organizers/tables.py b/organizers/tables.py index 818dc001d..dc16ab557 100755 --- a/organizers/tables.py +++ b/organizers/tables.py @@ -23,10 +23,10 @@ class ApplicationFilter(django_filters.FilterSet): def search_filter(self, queryset, name, value): return queryset.filter( - Q(user__email__icontains=value) - | Q(user__name__icontains=value) - | Q(university__icontains=value) - | Q(origin__icontains=value) + Q(user__email__unaccent__icontains=value) + | Q(user__name__unaccent__icontains=value) + | Q(university__unaccent__icontains=value) + | Q(origin__unaccent__icontains=value) ) class Meta: @@ -51,10 +51,10 @@ class DubiousApplicationFilter(django_filters.FilterSet): def search_filter(self, queryset, name, value): return queryset.filter( - Q(user__email__icontains=value) - | Q(user__name__icontains=value) - | Q(university__icontains=value) - | Q(origin__icontains=value) + Q(user__email__unaccent__icontains=value) + | Q(user__name__unaccent__icontains=value) + | Q(university__unaccent__icontains=value) + | Q(origin__unaccent__icontains=value) ) class Meta: @@ -67,10 +67,10 @@ class BlacklistApplicationFilter(django_filters.FilterSet): def search_filter(self, queryset, name, value): return queryset.filter( - Q(user__email__icontains=value) - | Q(user__name__icontains=value) - | Q(university__icontains=value) - | Q(origin__icontains=value) + Q(user__email__unaccent__icontains=value) + | Q(user__name__unaccent__icontains=value) + | Q(university__unaccent__icontains=value) + | Q(origin__unaccent__icontains=value) ) class Meta: @@ -94,10 +94,10 @@ class InviteFilter(django_filters.FilterSet): def search_filter(self, queryset, name, value): return queryset.filter( - Q(user__email__icontains=value) - | Q(user__name__icontains=value) - | Q(university__icontains=value) - | Q(origin__icontains=value) + Q(user__email__unaccent__icontains=value) + | Q(user__name__unaccent__icontains=value) + | Q(university__unaccent__icontains=value) + | Q(origin__unaccent__icontains=value) ) class Meta: @@ -273,9 +273,9 @@ class SponsorFilter(django_filters.FilterSet): def search_filter(self, queryset, name, value): return queryset.filter( - Q(email__icontains=value) - | Q(user__name__icontains=value) - | Q(name__icontains=value) + Q(email__unaccent__icontains=value) + | Q(user__name__unaccent__icontains=value) + | Q(name__unaccent__icontains=value) ) class Meta: @@ -307,7 +307,7 @@ class SponsorUserFilter(django_filters.FilterSet): search = django_filters.CharFilter(method="search_filter", label="Search") def search_filter(self, queryset, name, value): - return queryset.filter(Q(email__icontains=value) | Q(name__icontains=value)) + return queryset.filter(Q(email__unaccent__icontains=value) | Q(name__unaccent__icontains=value)) class Meta: model = User diff --git a/reimbursement/admin.py b/reimbursement/admin.py index ab46141ff..124fd0749 100644 --- a/reimbursement/admin.py +++ b/reimbursement/admin.py @@ -21,7 +21,7 @@ class ReimbursementAdmin(admin.ModelAdmin): ) list_filter = ("status", "origin", "reimbursed_by") - search_fields = ["hacker__name", "hacker__email", "origin"] + search_fields = ["hacker__name__unaccent", "hacker__email__unaccent", "origin__unaccent"] list_per_page = 200 ordering = ("creation_time",) diff --git a/reimbursement/tables.py b/reimbursement/tables.py index 2f241a192..b7dbb55fa 100644 --- a/reimbursement/tables.py +++ b/reimbursement/tables.py @@ -14,9 +14,9 @@ class ReimbursementFilter(django_filters.FilterSet): def search_filter(self, queryset, name, value): return queryset.filter( - Q(hacker__email__icontains=value) - | Q(hacker__name__icontains=value) - | Q(origin__icontains=value) + Q(hacker__email__unaccent__icontains=value) + | Q(hacker__name__unaccent__icontains=value) + | Q(origin__unaccent__icontains=value) ) class Meta: @@ -51,9 +51,9 @@ class SendReimbursementFilter(django_filters.FilterSet): def search_filter(self, queryset, name, value): return queryset.filter( - Q(hacker__email__icontains=value) - | Q(hacker__name__icontains=value) - | Q(origin__icontains=value) + Q(hacker__email__unaccent__icontains=value) + | Q(hacker__name__unaccent__icontains=value) + | Q(origin__unaccent__icontains=value) ) class Meta: diff --git a/teams/admin.py b/teams/admin.py index 346cd266b..205c0d1e5 100644 --- a/teams/admin.py +++ b/teams/admin.py @@ -8,7 +8,7 @@ class TeamAdmin(admin.ModelAdmin): list_display = ('team_code', 'user',) list_per_page = 200 list_filter = ('team_code', 'user') - search_fields = ('team_code', 'user__name') + search_fields = ('team_code', 'user__name__unaccent') actions = ['delete_selected', ] diff --git a/tests/test_unaccent_search.py b/tests/test_unaccent_search.py new file mode 100644 index 000000000..c65b2d057 --- /dev/null +++ b/tests/test_unaccent_search.py @@ -0,0 +1,33 @@ +import pytest +from django.contrib.auth import get_user_model + +from tests.factories import UserFactory + +User = get_user_model() + + +@pytest.mark.django_db +def test_unaccented_query_matches_accented_name(): + UserFactory(name="Gerard Màdrid") + + result = User.objects.filter(name__unaccent__icontains="madrid") + + assert result.count() == 1 + + +@pytest.mark.django_db +def test_accented_query_matches_unaccented_name(): + UserFactory(name="Gerard Madrid") + + result = User.objects.filter(name__unaccent__icontains="mÀdRíD") + + assert result.count() == 1 + + +@pytest.mark.django_db +def test_non_matching_query_returns_nothing(): + UserFactory(name="Gerard Màdrid") + + result = User.objects.filter(name__unaccent__icontains="mdrid") + + assert result.count() == 0 diff --git a/user/admin.py b/user/admin.py index 86b493131..b39d28fe4 100644 --- a/user/admin.py +++ b/user/admin.py @@ -42,7 +42,7 @@ class UserAdmin(admin.ModelAdmin): 'fields': ('email', 'name', 'password1', 'password2',)} ), ) - search_fields = ('email',) + search_fields = ('email__unaccent',) ordering = ('created_time',) date_hierarchy = 'created_time' filter_horizontal = () @@ -57,7 +57,7 @@ class BlacklistUserAdmin(admin.ModelAdmin): list_display = ('email', 'name', 'date_of_ban') list_per_page = 20 list_filter = ('email', 'name') - search_fields = ('email', 'name') + search_fields = ('email__unaccent', 'name__unaccent') actions = ['delete_selected', ] diff --git a/user/apps.py b/user/apps.py index 7fa8d1b41..12313797e 100644 --- a/user/apps.py +++ b/user/apps.py @@ -11,3 +11,5 @@ def ready(self): from .signals import user_organizer, user_verify_email user_organizer user_verify_email + from .lookups import register + register() diff --git a/user/lookups.py b/user/lookups.py new file mode 100644 index 000000000..d2e196d21 --- /dev/null +++ b/user/lookups.py @@ -0,0 +1,27 @@ +import unicodedata + +from django.db.backends.signals import connection_created +from django.db.models import CharField, TextField, Transform + + +def strip_accents(value): + if value is None: + return None + return ''.join(char for char in unicodedata.normalize('NFKD', value) if not unicodedata.combining(char)) + + +class Unaccent(Transform): + bilateral = True + lookup_name = 'unaccent' + function = 'UNACCENT' + + +def register_sqlite_unaccent(connection, **kwargs): + if connection.vendor == 'sqlite': + connection.connection.create_function('unaccent', 1, strip_accents, deterministic=True) + + +def register(): + CharField.register_lookup(Unaccent) + TextField.register_lookup(Unaccent) + connection_created.connect(register_sqlite_unaccent) diff --git a/user/migrations/0020_unaccent_extension.py b/user/migrations/0020_unaccent_extension.py new file mode 100644 index 000000000..7c049eede --- /dev/null +++ b/user/migrations/0020_unaccent_extension.py @@ -0,0 +1,13 @@ +from django.contrib.postgres.operations import UnaccentExtension +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('user', '0019_user_mlh_subscribed'), + ] + + operations = [ + UnaccentExtension(), + ] From 9cddd4f8a0f2ea5dbc8679154cf25220869a9fe1 Mon Sep 17 00:00:00 2001 From: Gerard Madrid Date: Mon, 24 Aug 2026 11:15:18 +0200 Subject: [PATCH 6/6] lint: wrap search filter lines over 120 chars Co-Authored-By: Claude Fable 5 --- baggage/admin.py | 3 ++- baggage/tables.py | 9 ++++++--- hardware/tables.py | 3 ++- organizers/admin.py | 3 ++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/baggage/admin.py b/baggage/admin.py index d6a19b3d6..aac323ea5 100644 --- a/baggage/admin.py +++ b/baggage/admin.py @@ -19,7 +19,8 @@ class BaggageListAdmin(admin.ModelAdmin): 'bid', 'owner', 'status', 'btype', 'color', 'description', 'special', 'time', 'updated' ) search_fields = ( - 'owner__email__unaccent', 'owner__name__unaccent', 'status__unaccent', 'btype__unaccent', 'color__unaccent', 'description__unaccent' + 'owner__email__unaccent', 'owner__name__unaccent', 'status__unaccent', 'btype__unaccent', + 'color__unaccent', 'description__unaccent' ) list_filter = ( 'status', 'btype', 'color', 'special' diff --git a/baggage/tables.py b/baggage/tables.py index 7a63c75e5..5068beed9 100644 --- a/baggage/tables.py +++ b/baggage/tables.py @@ -21,9 +21,12 @@ class BaggageListFilter(django_filters.FilterSet): def search_filter(self, queryset, name, value): queryfilter = queryset.annotate(fullpos=Concat('room', 'row', 'col', output_field=CharField())) - return queryfilter.filter((Q(owner__email__unaccent__icontains=value) | Q(owner__name__unaccent__icontains=value) | - Q(status__unaccent__icontains=value) | Q(btype__unaccent__icontains=value) | Q(color__unaccent__icontains=value) | - Q(description__unaccent__icontains=value) | Q(fullpos__unaccent__icontains=value))) + return queryfilter.filter((Q(owner__email__unaccent__icontains=value) | + Q(owner__name__unaccent__icontains=value) | + Q(status__unaccent__icontains=value) | Q(btype__unaccent__icontains=value) | + Q(color__unaccent__icontains=value) | + Q(description__unaccent__icontains=value) | + Q(fullpos__unaccent__icontains=value))) def search_time(self, queryset, name, value): if name == 'time_from': diff --git a/hardware/tables.py b/hardware/tables.py index bb30bc137..cef39c6f7 100644 --- a/hardware/tables.py +++ b/hardware/tables.py @@ -50,7 +50,8 @@ def status_filter(self, queryset, name, value): return queryset.get_active() def search_filter(self, queryset, name, value): - return queryset.filter(Q(item__item_type__name__unaccent__icontains=value) | Q(user__name__unaccent__icontains=value)) + return queryset.filter(Q(item__item_type__name__unaccent__icontains=value) | + Q(user__name__unaccent__icontains=value)) class Meta: model = Borrowing diff --git a/organizers/admin.py b/organizers/admin.py index 05b7592c8..368c4eefe 100644 --- a/organizers/admin.py +++ b/organizers/admin.py @@ -15,7 +15,8 @@ class VoteAdmin(admin.ModelAdmin): list_display = ('application', 'user', 'tech', 'personal', 'calculated_vote') list_per_page = 200 list_filter = ('user', 'application') - search_fields = ('application__user__name__unaccent', 'application__user__email__unaccent', 'user__name__unaccent', 'user__email__unaccent') + search_fields = ('application__user__name__unaccent', 'application__user__email__unaccent', + 'user__name__unaccent', 'user__email__unaccent') actions = ['delete_selected', ]