-
- {{room.name}} ({{room.room_type__name}})
-
-
-
Ver detalles
+
+
+
+{% if rooms|length == 0 %}
+
+ No se encontraron habitaciones.
+
+{% else %}
+ {% for room in rooms%}
+
+
+
+ {{room.name}} ({{room.room_type__name}})
+
+
+
-
-
-{% endfor %}
+ {% endfor %}
+{% endif %}
+
{% endblock content%}
diff --git a/pms/tests.py b/pms/tests.py
index 7ce503c2d..3ead2678d 100644
--- a/pms/tests.py
+++ b/pms/tests.py
@@ -1,3 +1,303 @@
-from django.test import TestCase
+from datetime import date, timedelta
-# Create your tests here.
+from django.test import TestCase, Client, override_settings
+from django.urls import reverse
+
+from .models import Booking, Customer, Room, Room_type
+from .forms import BookingDatesForm
+
+
+# ---------------------------------------------------------------------------
+# Base class: replaces ManifestStaticFilesStorage so templates render in tests
+# ---------------------------------------------------------------------------
+
+@override_settings(STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage')
+class PmsTestCase(TestCase):
+ pass
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def make_room_type(name="Individual", price=20.0, max_guests=1):
+ return Room_type.objects.create(name=name, price=price, max_guests=max_guests)
+
+
+def make_room(room_type, name="Room 1.1", description="Desc"):
+ return Room.objects.create(room_type=room_type, name=name, description=description)
+
+
+def make_customer(name="Test User", email="test@test.com", phone="600000000"):
+ return Customer.objects.create(name=name, email=email, phone=phone)
+
+
+def make_booking(room, customer, checkin, checkout, guests=1, state=Booking.NEW, total=None, code="TESTCODE"):
+ days = (checkout - checkin).days
+ return Booking.objects.create(
+ room=room,
+ customer=customer,
+ checkin=checkin,
+ checkout=checkout,
+ guests=guests,
+ state=state,
+ total=total if total is not None else days * room.room_type.price,
+ code=code,
+ )
+
+
+# ===========================================================================
+# 1. Filtro de habitaciones por nombre (RoomsView)
+# ===========================================================================
+
+class RoomsViewFilterTest(PmsTestCase):
+ def setUp(self):
+ self.client = Client()
+ rt = make_room_type()
+ self.room1 = make_room(rt, name="Room 1.1")
+ self.room2 = make_room(rt, name="Room 1.2")
+ self.room3 = make_room(rt, name="Room 2.1")
+ self.url = reverse("rooms")
+
+ def test_sin_filtro_muestra_todas_las_habitaciones(self):
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+ rooms = list(response.context["rooms"])
+ names = [r["name"] for r in rooms]
+ self.assertIn("Room 1.1", names)
+ self.assertIn("Room 1.2", names)
+ self.assertIn("Room 2.1", names)
+
+ def test_filtro_por_nombre_parcial_devuelve_coincidencias(self):
+ response = self.client.get(self.url, {"name": "Room 1"})
+ self.assertEqual(response.status_code, 200)
+ rooms = list(response.context["rooms"])
+ names = [r["name"] for r in rooms]
+ self.assertIn("Room 1.1", names)
+ self.assertIn("Room 1.2", names)
+ self.assertNotIn("Room 2.1", names)
+
+ def test_filtro_exacto_devuelve_una_habitacion(self):
+ response = self.client.get(self.url, {"name": "Room 2.1"})
+ self.assertEqual(response.status_code, 200)
+ rooms = list(response.context["rooms"])
+ self.assertEqual(len(rooms), 1)
+ self.assertEqual(rooms[0]["name"], "Room 2.1")
+
+ def test_filtro_insensible_a_mayusculas(self):
+ response = self.client.get(self.url, {"name": "room 1"})
+ self.assertEqual(response.status_code, 200)
+ rooms = list(response.context["rooms"])
+ names = [r["name"] for r in rooms]
+ self.assertIn("Room 1.1", names)
+ self.assertIn("Room 1.2", names)
+
+ def test_filtro_sin_resultados(self):
+ response = self.client.get(self.url, {"name": "XYZ"})
+ self.assertEqual(response.status_code, 200)
+ rooms = list(response.context["rooms"])
+ self.assertEqual(len(rooms), 0)
+
+ def test_contexto_incluye_name_filter(self):
+ response = self.client.get(self.url, {"name": "Room 1"})
+ self.assertEqual(response.context["name_filter"], "Room 1")
+
+ def test_contexto_name_filter_vacio_sin_parametro(self):
+ response = self.client.get(self.url)
+ self.assertEqual(response.context["name_filter"], "")
+
+
+# ===========================================================================
+# 2. Widget % ocupación (DashboardView)
+# ===========================================================================
+
+class DashboardOccupancyTest(PmsTestCase):
+ def setUp(self):
+ self.client = Client()
+ self.url = reverse("dashboard")
+ rt = make_room_type()
+ self.room1 = make_room(rt, name="Room 1.1")
+ self.room2 = make_room(rt, name="Room 1.2")
+ self.room3 = make_room(rt, name="Room 1.3")
+ self.customer = make_customer()
+
+ def test_ocupacion_cero_sin_reservas(self):
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.context["dashboard"]["occupancy"], 0.0)
+
+ def test_ocupacion_parcial(self):
+ # 1 de 3 habitaciones reservadas = 33.3 %
+ today = date.today()
+ make_booking(self.room1, self.customer, today, today + timedelta(days=2))
+ response = self.client.get(self.url)
+ occupancy = response.context["dashboard"]["occupancy"]
+ self.assertAlmostEqual(occupancy, 33.3, places=1)
+
+ def test_ocupacion_total(self):
+ # 3 de 3 = 100 %
+ today = date.today()
+ make_booking(self.room1, self.customer, today, today + timedelta(days=1))
+ make_booking(self.room2, self.customer, today, today + timedelta(days=1), code="CODE0002")
+ make_booking(self.room3, self.customer, today, today + timedelta(days=1), code="CODE0003")
+ response = self.client.get(self.url)
+ self.assertEqual(response.context["dashboard"]["occupancy"], 100.0)
+
+ def test_reservas_canceladas_no_cuentan_en_ocupacion(self):
+ today = date.today()
+ # una confirmada, una cancelada
+ make_booking(self.room1, self.customer, today, today + timedelta(days=1))
+ make_booking(self.room2, self.customer, today, today + timedelta(days=1),
+ state=Booking.DELETED, code="CODE0002")
+ response = self.client.get(self.url)
+ # solo 1 confirmada / 3 habitaciones = 33.3 %
+ occupancy = response.context["dashboard"]["occupancy"]
+ self.assertAlmostEqual(occupancy, 33.3, places=1)
+
+ def test_dashboard_renderiza_template_correcto(self):
+ response = self.client.get(self.url)
+ self.assertTemplateUsed(response, "dashboard.html")
+
+
+# ===========================================================================
+# 3. Editar fechas de reserva (EditBookingDatesView)
+# ===========================================================================
+
+class EditBookingDatesViewTest(PmsTestCase):
+ def setUp(self):
+ self.client = Client()
+ rt = make_room_type(price=30.0)
+ self.room = make_room(rt, name="Room 1.1")
+ self.customer = make_customer()
+ today = date.today()
+ self.checkin = today + timedelta(days=10)
+ self.checkout = today + timedelta(days=13) # 3 noches
+ self.booking = make_booking(
+ self.room, self.customer, self.checkin, self.checkout
+ )
+ self.url = reverse("edit_booking_dates", kwargs={"pk": self.booking.id})
+
+ # --- GET ---
+
+ def test_get_renderiza_formulario_con_fechas_actuales(self):
+ response = self.client.get(self.url)
+ self.assertEqual(response.status_code, 200)
+ self.assertTemplateUsed(response, "edit_booking_dates.html")
+ form = response.context["form"]
+ self.assertEqual(form.initial["checkin"], self.checkin)
+ self.assertEqual(form.initial["checkout"], self.checkout)
+
+ def test_get_incluye_booking_en_contexto(self):
+ response = self.client.get(self.url)
+ self.assertEqual(response.context["booking"], self.booking)
+
+ # --- POST válido sin conflicto ---
+
+ def test_post_fechas_validas_actualiza_reserva(self):
+ new_checkin = self.checkin + timedelta(days=5)
+ new_checkout = new_checkin + timedelta(days=4)
+ response = self.client.post(self.url, {
+ "checkin": new_checkin.strftime("%Y-%m-%d"),
+ "checkout": new_checkout.strftime("%Y-%m-%d"),
+ })
+ self.assertRedirects(response, "/", fetch_redirect_response=False)
+ self.booking.refresh_from_db()
+ self.assertEqual(self.booking.checkin, new_checkin)
+ self.assertEqual(self.booking.checkout, new_checkout)
+
+ def test_post_recalcula_total_correctamente(self):
+ new_checkin = self.checkin + timedelta(days=5)
+ new_checkout = new_checkin + timedelta(days=2) # 2 noches × 30€ = 60€
+ self.client.post(self.url, {
+ "checkin": new_checkin.strftime("%Y-%m-%d"),
+ "checkout": new_checkout.strftime("%Y-%m-%d"),
+ })
+ self.booking.refresh_from_db()
+ self.assertAlmostEqual(self.booking.total, 60.0)
+
+ # --- POST con conflicto ---
+
+ def test_post_conflicto_muestra_error(self):
+ """Otra reserva ocupa las mismas fechas en la misma habitación."""
+ conflict_customer = make_customer(name="Other", email="other@test.com")
+ make_booking(
+ self.room, conflict_customer,
+ self.checkin + timedelta(days=5),
+ self.checkin + timedelta(days=8),
+ code="CONFLICT",
+ )
+ response = self.client.post(self.url, {
+ "checkin": (self.checkin + timedelta(days=5)).strftime("%Y-%m-%d"),
+ "checkout": (self.checkin + timedelta(days=7)).strftime("%Y-%m-%d"),
+ })
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("error", response.context)
+ self.assertIn("No hay disponibilidad", response.context["error"])
+
+ def test_post_conflicto_no_modifica_fechas_originales(self):
+ conflict_customer = make_customer(name="Other2", email="other2@test.com")
+ make_booking(
+ self.room, conflict_customer,
+ self.checkin + timedelta(days=5),
+ self.checkin + timedelta(days=8),
+ code="CONF002",
+ )
+ self.client.post(self.url, {
+ "checkin": (self.checkin + timedelta(days=5)).strftime("%Y-%m-%d"),
+ "checkout": (self.checkin + timedelta(days=7)).strftime("%Y-%m-%d"),
+ })
+ self.booking.refresh_from_db()
+ self.assertEqual(self.booking.checkin, self.checkin)
+ self.assertEqual(self.booking.checkout, self.checkout)
+
+ def test_post_misma_reserva_no_genera_conflicto_consigo_misma(self):
+ """Guardar las mismas fechas no debe reportar conflicto."""
+ response = self.client.post(self.url, {
+ "checkin": self.checkin.strftime("%Y-%m-%d"),
+ "checkout": self.checkout.strftime("%Y-%m-%d"),
+ })
+ self.assertRedirects(response, "/", fetch_redirect_response=False)
+
+ # --- Validación del formulario ---
+
+ def test_post_checkout_anterior_a_checkin_es_invalido(self):
+ response = self.client.post(self.url, {
+ "checkin": self.checkout.strftime("%Y-%m-%d"),
+ "checkout": self.checkin.strftime("%Y-%m-%d"),
+ })
+ self.assertEqual(response.status_code, 200)
+ form = response.context["form"]
+ self.assertFalse(form.is_valid())
+
+ def test_post_fechas_vacias_es_invalido(self):
+ response = self.client.post(self.url, {"checkin": "", "checkout": ""})
+ self.assertEqual(response.status_code, 200)
+ form = response.context["form"]
+ self.assertFalse(form.is_valid())
+
+
+# ===========================================================================
+# 4. BookingDatesForm – validaciones unitarias
+# ===========================================================================
+
+class BookingDatesFormTest(TestCase):
+ def _form(self, checkin, checkout):
+ return BookingDatesForm(data={
+ "checkin": checkin.strftime("%Y-%m-%d"),
+ "checkout": checkout.strftime("%Y-%m-%d"),
+ })
+
+ def test_formulario_valido(self):
+ today = date.today()
+ form = self._form(today + timedelta(days=1), today + timedelta(days=3))
+ self.assertTrue(form.is_valid())
+
+ def test_checkout_igual_a_checkin_es_invalido(self):
+ today = date.today()
+ form = self._form(today + timedelta(days=1), today + timedelta(days=1))
+ self.assertFalse(form.is_valid())
+
+ def test_checkout_anterior_a_checkin_es_invalido(self):
+ today = date.today()
+ form = self._form(today + timedelta(days=3), today + timedelta(days=1))
+ self.assertFalse(form.is_valid())
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/
/", views.BookingView.as_view(), name="booking"),
path("booking//edit", views.EditBookingView.as_view(), name="edit_booking"),
+ path("booking//edit-dates", views.EditBookingDatesView.as_view(), name="edit_booking_dates"),
path("booking//delete", views.DeleteBookingView.as_view(), name="delete_booking"),
path("rooms/", views.RoomsView.as_view(), name="rooms"),
path("room//", views.RoomDetailsView.as_view(), name="room_details"),
diff --git a/pms/views.py b/pms/views.py
index f38563933..59167b7fb 100644
--- a/pms/views.py
+++ b/pms/views.py
@@ -8,6 +8,7 @@
from .forms import *
from .models import Room
from .reservation_code import generate
+from .models import Room as RoomModel
class BookingSearchView(View):
@@ -174,6 +175,60 @@ def post(self, request, pk):
return redirect("/")
+class EditBookingDatesView(View):
+ # renders the booking dates edition form
+ def get(self, request, pk):
+ booking = Booking.objects.get(id=pk)
+ form = BookingDatesForm(initial={
+ 'checkin': booking.checkin,
+ 'checkout': booking.checkout,
+ })
+ context = {
+ 'form': form,
+ 'booking': booking,
+ }
+ return render(request, "edit_booking_dates.html", context)
+
+ # validates availability and updates dates
+ def post(self, request, pk):
+ booking = Booking.objects.get(id=pk)
+ form = BookingDatesForm(request.POST)
+ if form.is_valid():
+ checkin = form.cleaned_data['checkin']
+ checkout = form.cleaned_data['checkout']
+ # check availability: any NEW booking on same room in requested dates, excluding current booking
+ conflict = (Booking.objects
+ .filter(
+ room=booking.room,
+ state=Booking.NEW,
+ checkin__lt=checkout,
+ checkout__gt=checkin,
+ )
+ .exclude(id=pk)
+ .exists())
+ if conflict:
+ context = {
+ 'form': form,
+ 'booking': booking,
+ 'error': 'No hay disponibilidad para las fechas seleccionadas.',
+ }
+ return render(request, "edit_booking_dates.html", context)
+ # recalculate total
+ total_days = (checkout - checkin).days
+ new_total = total_days * booking.room.room_type.price
+ Booking.objects.filter(id=pk).update(
+ checkin=checkin,
+ checkout=checkout,
+ total=new_total,
+ )
+ return redirect("/")
+ context = {
+ 'form': form,
+ 'booking': booking,
+ }
+ return render(request, "edit_booking_dates.html", context)
+
+
class DashboardView(View):
def get(self, request):
from datetime import date, time, datetime
@@ -209,13 +264,18 @@ def get(self, request):
.aggregate(Sum('total'))
)
+ # get occupancy rate: confirmed bookings / total rooms
+ total_rooms = RoomModel.objects.count()
+ confirmed_bookings = Booking.objects.filter(state="NEW").count()
+ occupancy = round((confirmed_bookings / total_rooms * 100), 1) if total_rooms > 0 else 0
+
# preparing context data
dashboard = {
'new_bookings': new_bookings,
'incoming_guests': incoming,
'outcoming_guests': outcoming,
- 'invoiced': invoiced
-
+ 'invoiced': invoiced,
+ 'occupancy': occupancy,
}
context = {
@@ -223,7 +283,6 @@ def get(self, request):
}
return render(request, "dashboard.html", context)
-
class RoomDetailsView(View):
def get(self, request, pk):
# renders room details
@@ -238,9 +297,13 @@ def get(self, request, pk):
class RoomsView(View):
def get(self, request):
- # renders a list of rooms
+ # renders a list of rooms, with optional name filter
+ name_filter = request.GET.get('name', '').strip()
rooms = Room.objects.all().values("name", "room_type__name", "id")
+ if name_filter:
+ rooms = rooms.filter(name__icontains=name_filter)
context = {
- 'rooms': rooms
+ 'rooms': rooms,
+ 'name_filter': name_filter,
}
return render(request, "rooms.html", context)
diff --git a/requirements.txt b/requirements.txt
index 148e2c607..82dda7d83 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,3 +2,4 @@ asgiref==3.5.0
Django==4.0.2
sqlparse==0.4.2
whitenoise
+legacy-cgi