diff --git a/pms/forms.py b/pms/forms.py index f1bc68d08..1bf5bd689 100644 --- a/pms/forms.py +++ b/pms/forms.py @@ -1,9 +1,12 @@ -from datetime import datetime +from datetime import date, datetime from django import forms from django.forms import ModelForm from .models import Booking, Customer +MAX_BOOKING_DATE = date(2026, 12, 31) + + class RoomSearchForm(ModelForm): class Meta: @@ -56,3 +59,66 @@ class Meta: 'total': forms.HiddenInput(), 'state': forms.HiddenInput(), } + + +class BookingDatesForm(forms.Form): + checkin = forms.DateField( + label="Check-in", + widget=forms.DateInput(attrs={ + "type": "date", + "min": date.today().isoformat(), + "max": MAX_BOOKING_DATE.isoformat(), + "class": "form-control", + }), + ) + checkout = forms.DateField( + label="Check-out", + widget=forms.DateInput(attrs={ + "type": "date", + "min": date.today().isoformat(), + "max": MAX_BOOKING_DATE.isoformat(), + "class": "form-control", + }), + ) + + def __init__(self, *args, room, booking_id, **kwargs): + super().__init__(*args, **kwargs) + self._room = room + self._booking_id = booking_id + + def clean(self): + cleaned_data = super().clean() + checkin = cleaned_data.get("checkin") + checkout = cleaned_data.get("checkout") + + if not checkin or not checkout: + return cleaned_data + + if checkout <= checkin: + raise forms.ValidationError( + "La fecha de check-out debe ser posterior al check-in." + ) + + if checkout > MAX_BOOKING_DATE: + raise forms.ValidationError( + f"La fecha máxima de check-out es {MAX_BOOKING_DATE.strftime('%d/%m/%Y')}." + ) + + overlaps = ( + Booking.objects + .filter( + room=self._room, + state=Booking.NEW, + checkin__lt=checkout, + checkout__gt=checkin, + ) + .exclude(pk=self._booking_id) + .exists() + ) + if overlaps: + raise forms.ValidationError( + "No hay disponibilidad para las fechas seleccionadas." + ) + + return cleaned_data + 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 @@
+ Reserva {{ booking.code }} — {{ booking.room.name }} +
+ + {% if form.non_field_errors %} ++ {% if rooms %} + {{ rooms|length }} resultado{{ rooms|length|pluralize:"s" }} para "{{ query }}" + {% else %} + No se encontraron habitaciones para "{{ query }}". + {% endif %} +
+{% endif %} + +{% for room in rooms %}No hay habitaciones registradas.
+{% endif %} {% endfor %} -{% endblock content%} +{% endblock content %} + diff --git a/pms/tests.py b/pms/tests.py index 7ce503c2d..4f5029fde 100644 --- a/pms/tests.py +++ b/pms/tests.py @@ -1,3 +1,312 @@ -from django.test import TestCase +from datetime import date -# Create your tests here. +from django.test import TestCase, Client +from django.urls import reverse + +from .models import Booking, Customer, 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)) + + +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) + + +class EditBookingDatesViewTest(TestCase): + """Integration tests for the booking date editing feature.""" + + @classmethod + def setUpTestData(cls): + room_type = Room_type.objects.create(name="Estándar", price=100.0, max_guests=2) + customer = Customer.objects.create(name="Luis Pérez", email="luis@example.com", phone="611000000") + + cls.room = Room.objects.create(name="Hab 201", description="", room_type=room_type) + cls.other_room = Room.objects.create(name="Hab 202", description="", room_type=room_type) + + # The booking we will edit in most tests + cls.booking = Booking.objects.create( + checkin=date(2026, 6, 1), + checkout=date(2026, 6, 5), + room=cls.room, + guests=1, + customer=customer, + total=400.0, + code="EDIT0001", + state=Booking.NEW, + ) + + # An overlapping booking on the same room (by a different customer) + cls.blocking_booking = Booking.objects.create( + checkin=date(2026, 6, 10), + checkout=date(2026, 6, 15), + room=cls.room, + guests=1, + customer=customer, + total=500.0, + code="EDIT0002", + state=Booking.NEW, + ) + + # A cancelled booking on the same room — must NOT block dates + cls.cancelled_booking = Booking.objects.create( + checkin=date(2026, 7, 1), + checkout=date(2026, 7, 5), + room=cls.room, + guests=1, + customer=customer, + total=400.0, + code="EDIT0003", + state=Booking.DELETED, + ) + + def setUp(self): + self.client = Client() + self.url = reverse("edit_booking_dates", kwargs={"pk": self.booking.pk}) + + # --- GET --- + + def test_get_returns_200(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_get_prepopulates_form_with_current_dates(self): + response = self.client.get(self.url) + form = response.context["form"] + self.assertEqual(form.initial["checkin"], self.booking.checkin) + self.assertEqual(form.initial["checkout"], self.booking.checkout) + + # --- successful update --- + + def test_successful_date_change_redirects_to_home(self): + response = self.client.post(self.url, {"checkin": "2026-08-01", "checkout": "2026-08-05"}) + self.assertRedirects(response, reverse("home")) + + def test_successful_date_change_persists_in_db(self): + self.client.post(self.url, {"checkin": "2026-08-01", "checkout": "2026-08-05"}) + self.booking.refresh_from_db() + self.assertEqual(str(self.booking.checkin), "2026-08-01") + self.assertEqual(str(self.booking.checkout), "2026-08-05") + + def test_successful_date_change_recalculates_total(self): + # 4 nights × 100 €/night = 400 € + self.client.post(self.url, {"checkin": "2026-08-01", "checkout": "2026-08-05"}) + self.booking.refresh_from_db() + self.assertEqual(self.booking.total, 400.0) + + def test_editing_same_dates_is_allowed(self): + """A booking must not conflict with itself.""" + response = self.client.post(self.url, {"checkin": "2026-06-01", "checkout": "2026-06-05"}) + self.assertRedirects(response, reverse("home")) + + # --- overlap validation --- + + def test_overlap_with_other_booking_is_blocked(self): + # Tries to move into the window occupied by blocking_booking (2026-06-10 / 2026-06-15) + response = self.client.post(self.url, {"checkin": "2026-06-12", "checkout": "2026-06-14"}) + self.assertEqual(response.status_code, 200) + self.assertFormError( + response.context["form"], + None, + "No hay disponibilidad para las fechas seleccionadas.", + ) + + def test_partial_overlap_at_start_is_blocked(self): + response = self.client.post(self.url, {"checkin": "2026-06-08", "checkout": "2026-06-12"}) + self.assertEqual(response.status_code, 200) + self.assertFormError( + response.context["form"], + None, + "No hay disponibilidad para las fechas seleccionadas.", + ) + + def test_partial_overlap_at_end_is_blocked(self): + response = self.client.post(self.url, {"checkin": "2026-06-13", "checkout": "2026-06-17"}) + self.assertEqual(response.status_code, 200) + self.assertFormError( + response.context["form"], + None, + "No hay disponibilidad para las fechas seleccionadas.", + ) + + def test_cancelled_booking_does_not_block_dates(self): + # cancelled_booking sits on 2026-07-01 / 2026-07-05 — must be ignored + response = self.client.post(self.url, {"checkin": "2026-07-01", "checkout": "2026-07-05"}) + self.assertRedirects(response, reverse("home")) + + # --- date constraint validation --- + + def test_checkout_before_checkin_is_invalid(self): + response = self.client.post(self.url, {"checkin": "2026-09-10", "checkout": "2026-09-05"}) + self.assertEqual(response.status_code, 200) + self.assertFormError( + response.context["form"], + None, + "La fecha de check-out debe ser posterior al check-in.", + ) + + def test_checkout_same_as_checkin_is_invalid(self): + response = self.client.post(self.url, {"checkin": "2026-09-10", "checkout": "2026-09-10"}) + self.assertEqual(response.status_code, 200) + self.assertFormError( + response.context["form"], + None, + "La fecha de check-out debe ser posterior al check-in.", + ) + + def test_checkout_beyond_max_date_is_invalid(self): + response = self.client.post(self.url, {"checkin": "2026-12-30", "checkout": "2027-01-02"}) + self.assertEqual(response.status_code, 200) + self.assertFormError( + response.context["form"], + None, + "La fecha máxima de check-out es 31/12/2026.", + ) diff --git a/pms/urls.py b/pms/urls.py index c18714abf..2acbd0a22 100644 --- a/pms/urls.py +++ b/pms/urls.py @@ -8,6 +8,7 @@ path("search/booking/", views.BookingSearchView.as_view(), name="booking_search"), path("booking/