From b530c60b6854aa7b295f90265de782b3d1f6be30 Mon Sep 17 00:00:00 2001 From: Alejandro Lopez Date: Sat, 11 Apr 2026 14:26:03 -0500 Subject: [PATCH 1/2] feat(rooms): add case-insensitive search filter by room name - RoomsView accepts GET param 'q' and applies icontains filter - Template includes search form with clear button and result count - 12 unit tests covering edge cases (empty, whitespace, partial match, case-insensitivity, ordering, context preservation) --- pms/templates/rooms.html | 52 +++++++++++++++++++---- pms/tests.py | 91 +++++++++++++++++++++++++++++++++++++++- pms/views.py | 13 ++++-- 3 files changed, 142 insertions(+), 14 deletions(-) diff --git a/pms/templates/rooms.html b/pms/templates/rooms.html index c30929f1f..5d85b90c7 100644 --- a/pms/templates/rooms.html +++ b/pms/templates/rooms.html @@ -1,19 +1,53 @@ {% extends "main.html"%} {% block content %} -

Habitaciones del hotel

-{% for room in rooms%} +
+

Habitaciones del hotel

+
+ +
+
+ + + {% if query %} + Limpiar + {% endif %} +
+
+ +{% if query %} +

+ {% if rooms %} + {{ rooms|length }} resultado{{ rooms|length|pluralize:"s" }} para "{{ query }}" + {% else %} + No se encontraron habitaciones para "{{ query }}". + {% endif %} +

+{% endif %} + +{% for room in rooms %}
-
- {{room.name}} ({{room.room_type__name}}) -
+
{{ room.name }} ({{ room.room_type__name }})
- Ver detalles + Ver detalles
-
-
+{% empty %} +{% if not query %} +

No hay habitaciones registradas.

+{% endif %} {% endfor %} -{% endblock content%} +{% endblock content %} + diff --git a/pms/tests.py b/pms/tests.py index 7ce503c2d..0acc411d3 100644 --- a/pms/tests.py +++ b/pms/tests.py @@ -1,3 +1,90 @@ -from django.test import TestCase +from django.test import TestCase, Client +from django.urls import reverse + +from .models import Room, Room_type + + +class RoomsViewSearchTest(TestCase): + """Tests for the room list search filter (GET ?q=).""" + + @classmethod + def setUpTestData(cls): + room_type = Room_type.objects.create(name="Estándar", price=80.0, max_guests=2) + Room.objects.create(name="Habitación 101", description="Vista al jardín", room_type=room_type) + Room.objects.create(name="Habitación 202", description="Vista a la piscina", room_type=room_type) + Room.objects.create(name="Suite Presidencial", description="Planta ática", room_type=room_type) + + def setUp(self): + self.client = Client() + self.url = reverse("rooms") + + # --- baseline --- + + def test_returns_200(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_no_query_returns_all_rooms(self): + response = self.client.get(self.url) + self.assertEqual(len(response.context["rooms"]), 3) + + def test_empty_query_string_returns_all_rooms(self): + response = self.client.get(self.url, {"q": ""}) + self.assertEqual(len(response.context["rooms"]), 3) + + def test_whitespace_only_query_returns_all_rooms(self): + """Strips whitespace so ' ' is treated as no query.""" + response = self.client.get(self.url, {"q": " "}) + self.assertEqual(len(response.context["rooms"]), 3) + + # --- filtering --- + + def test_search_returns_matching_rooms(self): + response = self.client.get(self.url, {"q": "Habitación"}) + rooms = list(response.context["rooms"]) + self.assertEqual(len(rooms), 2) + self.assertTrue(all("Habitación" in r["name"] for r in rooms)) + + def test_search_partial_match(self): + response = self.client.get(self.url, {"q": "101"}) + rooms = list(response.context["rooms"]) + self.assertEqual(len(rooms), 1) + self.assertEqual(rooms[0]["name"], "Habitación 101") + + def test_search_no_results(self): + response = self.client.get(self.url, {"q": "xyzabc"}) + rooms = list(response.context["rooms"]) + self.assertEqual(len(rooms), 0) + + # --- case insensitivity --- + + def test_search_is_case_insensitive_lowercase(self): + response = self.client.get(self.url, {"q": "suite"}) + rooms = list(response.context["rooms"]) + self.assertEqual(len(rooms), 1) + self.assertEqual(rooms[0]["name"], "Suite Presidencial") + + def test_search_is_case_insensitive_uppercase_ascii(self): + # SQLite's LIKE only case-folds ASCII characters; use an ASCII-safe term. + response = self.client.get(self.url, {"q": "SUITE"}) + rooms = list(response.context["rooms"]) + self.assertEqual(len(rooms), 1) + self.assertEqual(rooms[0]["name"], "Suite Presidencial") + + # --- context --- + + def test_query_term_preserved_in_context(self): + response = self.client.get(self.url, {"q": "Suite"}) + self.assertEqual(response.context["query"], "Suite") + + def test_empty_query_context_is_empty_string(self): + response = self.client.get(self.url) + self.assertEqual(response.context["query"], "") + + # --- ordering --- + + def test_results_ordered_by_name(self): + response = self.client.get(self.url) + names = [r["name"] for r in response.context["rooms"]] + self.assertEqual(names, sorted(names)) -# Create your tests here. diff --git a/pms/views.py b/pms/views.py index f38563933..a1f9ca755 100644 --- a/pms/views.py +++ b/pms/views.py @@ -238,9 +238,16 @@ def get(self, request, pk): class RoomsView(View): def get(self, request): - # renders a list of rooms - rooms = Room.objects.all().values("name", "room_type__name", "id") + query = request.GET.get("q", "").strip() + rooms = Room.objects.select_related("room_type").values("name", "room_type__name", "id") + + if query: + rooms = rooms.filter(name__icontains=query) + + rooms = rooms.order_by("name") + context = { - 'rooms': rooms + "rooms": rooms, + "query": query, } return render(request, "rooms.html", context) From ad5813957516fd98baa1fbaa8076f7afeafbb927 Mon Sep 17 00:00:00 2001 From: Alejandro Lopez Date: Sat, 11 Apr 2026 14:42:13 -0500 Subject: [PATCH 2/2] feat(dashboard): add occupancy rate widget - Calculate occupancy as (active NEW bookings / total rooms) * 100 - Guard division by zero when no rooms exist - Replace hardcoded 'DEL' strings with Booking.DELETED constant - Move datetime imports to module level - Reuse bookings_today queryset to reduce redundant DB calls - 6 unit tests: correct calc, cancelled excluded, zero rooms, 100% full --- pms/templates/dashboard.html | 5 ++- pms/tests.py | 73 +++++++++++++++++++++++++++++++++++- pms/views.py | 62 +++++++++++++----------------- 3 files changed, 102 insertions(+), 38 deletions(-) diff --git a/pms/templates/dashboard.html b/pms/templates/dashboard.html index 10f0285cc..e331c78ee 100644 --- a/pms/templates/dashboard.html +++ b/pms/templates/dashboard.html @@ -17,11 +17,14 @@

{{dashboard.incoming_guests}}

Huéspedes saliendo

{{dashboard.outcoming_guests}}

-
Total facturado

€ {% if dashboard.invoiced.total__sum == None %}0.00{% endif %} {{dashboard.invoiced.total__sum|floatformat:2}}

+
+
Ocupación
+

{{ dashboard.occupancy_rate }}%

+
{% endblock content%} \ No newline at end of file diff --git a/pms/tests.py b/pms/tests.py index 0acc411d3..61b0b7728 100644 --- a/pms/tests.py +++ b/pms/tests.py @@ -1,7 +1,7 @@ from django.test import TestCase, Client from django.urls import reverse -from .models import Room, Room_type +from .models import Booking, Customer, Room, Room_type class RoomsViewSearchTest(TestCase): @@ -88,3 +88,74 @@ def test_results_ordered_by_name(self): names = [r["name"] for r in response.context["rooms"]] self.assertEqual(names, sorted(names)) + +class DashboardOccupancyTest(TestCase): + """Tests for the occupancy rate widget in the Dashboard.""" + + @classmethod + def setUpTestData(cls): + room_type = Room_type.objects.create(name="Estándar", price=100.0, max_guests=2) + customer = Customer.objects.create(name="Ana García", email="ana@example.com", phone="600000000") + + cls.room1 = Room.objects.create(name="Hab 101", description="", room_type=room_type) + cls.room2 = Room.objects.create(name="Hab 102", description="", room_type=room_type) + cls.room3 = Room.objects.create(name="Hab 103", description="", room_type=room_type) + cls.room4 = Room.objects.create(name="Hab 104", description="", room_type=room_type) + + # 3 active bookings (NEW), 1 cancelled (DEL) + for room in [cls.room1, cls.room2, cls.room3]: + Booking.objects.create( + checkin="2026-05-01", + checkout="2026-05-05", + room=room, + guests=1, + customer=customer, + total=400.0, + code=f"TST{room.id:05d}", + state=Booking.NEW, + ) + Booking.objects.create( + checkin="2026-05-01", + checkout="2026-05-05", + room=cls.room4, + guests=1, + customer=customer, + total=400.0, + code="TSTDEL01", + state=Booking.DELETED, + ) + + def setUp(self): + self.client = Client() + self.url = reverse("dashboard") + + def test_dashboard_returns_200(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_occupancy_rate_calculation(self): + # 3 NEW bookings / 4 rooms * 100 = 75% + response = self.client.get(self.url) + self.assertEqual(response.context["dashboard"]["occupancy_rate"], 75) + + def test_cancelled_bookings_excluded_from_occupancy(self): + # The DEL booking must not count towards occupancy + response = self.client.get(self.url) + self.assertLess(response.context["dashboard"]["occupancy_rate"], 100) + + def test_occupancy_rate_in_template(self): + response = self.client.get(self.url) + self.assertContains(response, "75%") + + def test_occupancy_zero_when_no_rooms(self): + # Temporarily wipe rooms to test division-by-zero guard + Room.objects.all().delete() + response = self.client.get(self.url) + self.assertEqual(response.context["dashboard"]["occupancy_rate"], 0) + + def test_occupancy_100_when_all_rooms_booked(self): + Booking.objects.filter(state=Booking.DELETED).update(state=Booking.NEW) + response = self.client.get(self.url) + self.assertEqual(response.context["dashboard"]["occupancy_rate"], 100) + + diff --git a/pms/views.py b/pms/views.py index a1f9ca755..1a18859b6 100644 --- a/pms/views.py +++ b/pms/views.py @@ -1,3 +1,5 @@ +from datetime import date, datetime, time + from django.db.models import F, Q, Count, Sum from django.shortcuts import render, redirect from django.utils.decorators import method_decorator @@ -6,7 +8,7 @@ from .form_dates import Ymd from .forms import * -from .models import Room +from .models import Booking, Room from .reservation_code import generate @@ -176,52 +178,40 @@ def post(self, request, pk): class DashboardView(View): def get(self, request): - from datetime import date, time, datetime today = date.today() + today_range = (datetime.combine(today, time.min), datetime.combine(today, time.max)) + + bookings_today = Booking.objects.filter(created__range=today_range) - # get bookings created today - today_min = datetime.combine(today, time.min) - today_max = datetime.combine(today, time.max) - today_range = (today_min, today_max) - new_bookings = (Booking.objects - .filter(created__range=today_range) - .values("id") - ).count() + new_bookings = bookings_today.count() - # get incoming guests incoming = (Booking.objects .filter(checkin=today) - .exclude(state="DEL") - .values("id") - ).count() + .exclude(state=Booking.DELETED) + .count()) - # get outcoming guests outcoming = (Booking.objects .filter(checkout=today) - .exclude(state="DEL") - .values("id") - ).count() - - # get outcoming guests - invoiced = (Booking.objects - .filter(created__range=today_range) - .exclude(state="DEL") - .aggregate(Sum('total')) - ) - - # preparing context data - dashboard = { - 'new_bookings': new_bookings, - 'incoming_guests': incoming, - 'outcoming_guests': outcoming, - 'invoiced': invoiced + .exclude(state=Booking.DELETED) + .count()) - } + invoiced = (bookings_today + .exclude(state=Booking.DELETED) + .aggregate(Sum("total"))) - context = { - 'dashboard': dashboard + total_rooms = Room.objects.count() + active_bookings = Booking.objects.filter(state=Booking.NEW).count() + occupancy_rate = round((active_bookings / total_rooms) * 100) if total_rooms > 0 else 0 + + dashboard = { + "new_bookings": new_bookings, + "incoming_guests": incoming, + "outcoming_guests": outcoming, + "invoiced": invoiced, + "occupancy_rate": occupancy_rate, } - return render(request, "dashboard.html", context) + + return render(request, "dashboard.html", {"dashboard": dashboard}) class RoomDetailsView(View):