- {% set indicator = "darkgrey" if doc.ends_on < frappe.utils.now_datetime() else ("red" if doc.status=="Cancelled" else ("blue" if doc.status=="Confirmed" else "orange")) %}
-
{{ doc.item_name }}
+
+
{{ doc.item_name |e }}
- {{ _(doc.status) }}
+ {{ _(doc.status) |e }}
-
-
+
+
{{ frappe.utils.global_date_format(doc.starts_on) }}
- {{ frappe.utils.get_time(doc.starts_on).strftime("%H:%M") }}-{{ frappe.utils.get_time(doc.ends_on).strftime("%H:%M") }}
+ {{ frappe.utils.get_time(doc.starts_on).strftime("%H:%M") }}—{{ frappe.utils.get_time(doc.ends_on).strftime("%H:%M") }}
{% if doc.repeat_this_event %}
- {{ _("Repeated event") }}
+ {{ _("Repeated event") }}
+
{% endif %}
-
- {% if doc.status != "Cancelled" and doc.starts_on > frappe.utils.add_to_date(frappe.utils.now_datetime(), minutes=cancellation_delay) and can_cancel %}
{{ _("Cancel") }} {% endif %}
+ {% if doc.status != "In cart" %}
+
+ {% if doc.status != "Cancelled" and doc.starts_on <= now <= doc.ends_on %}
+
+ {{ _("Set as completed") }}
+
+ {% elif doc.status != "Cancelled" and doc.starts_on > frappe.utils.add_to_date(now, minutes=cancellation_delay) and can_cancel %}
+
+ {{ _("Cancel") }}
+
+ {% endif %}
+ {% endif %}
-
-
-
+
\ No newline at end of file
diff --git a/erpnext/venue/doctype/item_booking/item_booking.js b/erpnext/venue/doctype/item_booking/item_booking.js
index 7cf76adb865364a1615fdf49b794100e5c2b57a9..30132e707fdc35075fa13c4f1ead209b1b14e2db 100644
--- a/erpnext/venue/doctype/item_booking/item_booking.js
+++ b/erpnext/venue/doctype/item_booking/item_booking.js
@@ -86,6 +86,8 @@ frappe.ui.form.on('Item Booking', {
}
frm.trigger('add_repeat_text');
+
+ frm.trigger('add_end_booking_button');
},
set_cart_countdown(frm) {
frappe.db.get_single_value("Venue Settings", "clear_item_booking_draft_duration")
@@ -192,6 +194,21 @@ frappe.ui.form.on('Item Booking', {
ends_on: function(frm) {
frm.trigger('get_booking_count')
},
+ add_end_booking_button: function(frm) {
+ if (frm.doc.status === "Cancelled" || !frm.has_perm("write")) {
+ return
+ }
+ if (frappe.datetime.get_minute_diff(frm.doc.starts_on) > 0) {
+ return // Not yet started
+ }
+ if (frappe.datetime.get_minute_diff(frm.doc.ends_on) < 1) {
+ return // Already ended
+ }
+ frm.page.add_action_item(__("Set as completed"), async () => {
+ await frm.call("end_now");
+ frm.save();
+ })
+ }
});
const add_to_transaction = (frm, transaction_type) => {
diff --git a/erpnext/venue/doctype/item_booking/item_booking.json b/erpnext/venue/doctype/item_booking/item_booking.json
index b9ec7a0b99a5cf7361485bb085183a90e6b798e0..6c049a9526c37863db5f2ade55e358927c37a06a 100644
--- a/erpnext/venue/doctype/item_booking/item_booking.json
+++ b/erpnext/venue/doctype/item_booking/item_booking.json
@@ -54,7 +54,8 @@
"in_standard_filter": 1,
"label": "Item",
"options": "Item",
- "reqd": 1
+ "reqd": 1,
+ "search_index": 1
},
{
"fieldname": "column_break_2",
@@ -261,7 +262,8 @@
"fieldname": "parent_item_booking",
"fieldtype": "Link",
"label": "Parent Item Booking",
- "options": "Item Booking"
+ "options": "Item Booking",
+ "search_index": 1
},
{
"fieldname": "google_calendar_tab",
@@ -291,7 +293,7 @@
"link_fieldname": "item_booking"
}
],
- "modified": "2024-01-23 17:40:02.178550",
+ "modified": "2024-02-13 18:35:54.430371",
"modified_by": "Administrator",
"module": "Venue",
"name": "Item Booking",
@@ -345,21 +347,6 @@
"role": "Sales User",
"share": 1,
"write": 1
- },
- {
- "email": 1,
- "export": 1,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "All",
- "share": 1
- },
- {
- "create": 1,
- "read": 1,
- "role": "Customer",
- "write": 1
}
],
"quick_entry": 1,
diff --git a/erpnext/venue/doctype/item_booking/item_booking.py b/erpnext/venue/doctype/item_booking/item_booking.py
index 7a2aa43dde7caabc9a162fae71abf19d0175af21..70f11c2578588298e69922acfa3e0ac0f98db5cf 100644
--- a/erpnext/venue/doctype/item_booking/item_booking.py
+++ b/erpnext/venue/doctype/item_booking/item_booking.py
@@ -32,10 +32,10 @@ from frappe.utils import (
sbool,
time_diff_in_minutes,
)
-from frappe.utils.user import is_system_user, is_website_user
from googleapiclient.errors import HttpError
from erpnext.accounts.party import get_party_account_currency
+from erpnext.controllers.website_list_for_contact import get_customers_suppliers
from erpnext.setup.utils import get_exchange_rate
from erpnext.venue.doctype.booking_credit.booking_credit import get_booking_credit_types_for_item
from erpnext.venue.doctype.item_booking_calendar.item_booking_calendar import (
@@ -484,6 +484,30 @@ class ItemBooking(Document):
self.set_status("Cancelled")
+ @frappe.whitelist()
+ def end_now(self):
+ new_end = now_datetime()
+ old_start = get_datetime(self.starts_on)
+ old_end = get_datetime(self.ends_on)
+ assert old_start and old_end # for typechecking
+
+ if old_start <= new_end <= old_end:
+ self.ends_on = new_end
+ return
+ else:
+ frappe.throw(
+ _("Date must be between {0} and {1}").format(
+ self.get_formatted("starts_on"),
+ self.get_formatted("ends_on"),
+ )
+ )
+
+ def has_website_permission(self, ptype, user, verbose=False):
+ if ptype == "read":
+ # Read-only, user has to use the website to cancel the bookings.
+ return has_booking_permission(self, ptype=ptype, user=user, raise_exception=False)
+ return False
+
def get_list_context(context=None):
allow_event_cancellation = frappe.db.get_single_value(
@@ -520,19 +544,25 @@ def get_bookings_list(doctype, txt, filters, limit_start, limit_page_length=20,
from frappe.www.list import get_list
user = frappe.session.user
- contact = frappe.db.get_value("Contact", {"user": user}, "name")
- customer = None
+
+ if user == "Guest":
+ return []
+
+ customers = set()
or_filters = []
+ contact = frappe.db.get_value("Contact", {"user": user}, "name")
if contact:
contact_doc = frappe.get_doc("Contact", contact)
- customer = contact_doc.get_link_for("Customer")
+ if customer := contact_doc.get_link_for("Customer"):
+ customers.add(customer)
- if is_website_user() or is_system_user(user):
- if not filters:
- filters = []
+ all_customers, _ = get_customers_suppliers("Customer", user)
+ customers.update(all_customers or [])
- or_filters.append({"user": user, "party_name": customer})
+ or_filters.append(["user", "=", user])
+ if customers:
+ or_filters.append(["party_name", "in", customers])
return get_list(
doctype,
@@ -540,13 +570,13 @@ def get_bookings_list(doctype, txt, filters, limit_start, limit_page_length=20,
filters,
limit_start,
limit_page_length,
- ignore_permissions=False,
+ ignore_permissions=True,
or_filters=or_filters,
order_by="starts_on desc",
)
-@frappe.whitelist()
+@frappe.whitelist(allow_guest=True)
def get_bookings_list_for_map(start, end):
bookings_list = _get_events(getdate(start), getdate(end), item=None, user=frappe.session.user)
@@ -585,11 +615,13 @@ def get_bookings_list_for_map(start, end):
@frappe.whitelist()
def update_linked_transaction(transaction_type, line_item, item_booking):
+ has_booking_permission(item_booking, raise_exception=True)
return frappe.db.set_value(f"{transaction_type} Item", line_item, "item_booking", item_booking)
@frappe.whitelist()
def get_transactions_items(transaction_type, transactions):
+ frappe.has_permission(transaction_type, "read", throw=True)
transactions = frappe.parse_json(transactions)
output = []
for transaction in transactions:
@@ -599,10 +631,58 @@ def get_transactions_items(transaction_type, transactions):
return output
+def has_booking_permission(doc: ItemBooking | str, ptype="write", user="", raise_exception=False):
+ from frappe.permissions import has_permission
+
+ if raise_exception:
+ if not has_booking_permission(doc, ptype, user, raise_exception=False):
+ frappe.throw("Not allowed", frappe.PermissionError)
+
+ user = user or frappe.session.user
+ if isinstance(doc, str):
+ doc: ItemBooking = frappe.get_doc("Item Booking", str) # type: ignore
+
+ if has_permission(doc.doctype, ptype, doc=doc, print_logs=False, user=user):
+ return True
+
+ if doc.user == user:
+ return True
+
+ customers, _ = get_customers_suppliers("Customer", user)
+ if customers:
+ for c in customers:
+ if doc.party_name == c:
+ return True
+
+ return False
+
+
@frappe.whitelist()
-def cancel_appointment(id, force=False):
+def cancel_appointment(id, force=False, render_row=False):
booking: ItemBooking = frappe.get_doc("Item Booking", id) # type: ignore
+ has_booking_permission(booking, raise_exception=True)
+ booking.flags.ignore_permissions = True
booking.cancel_appointment(ignore_links=force)
+ booking.save()
+ if render_row:
+ return do_render_row(booking)
+
+
+@frappe.whitelist()
+def end_booking_now(id, render_row=False):
+ booking: ItemBooking = frappe.get_doc("Item Booking", id) # type: ignore
+ has_booking_permission(booking, raise_exception=True)
+ booking.flags.ignore_permissions = True
+ booking.end_now()
+ booking.save()
+ if render_row:
+ return do_render_row(booking)
+
+
+def do_render_row(doc: ItemBooking):
+ ctx = {"doc": doc}
+ get_list_context(ctx)
+ return frappe.render_template(ctx["row_template"], ctx)
@frappe.whitelist(allow_guest=True)
@@ -647,6 +727,7 @@ def book_new_slot(**kwargs):
@frappe.whitelist()
def remove_booked_slot(name):
+ has_booking_permission(name, raise_exception=True)
try:
for dt in ["Quotation", "Sales Order"]:
linked_docs = frappe.get_all(
@@ -1043,6 +1124,9 @@ def _get_events(
from pypika import Criterion
from pypika import functions as fn
+ if user == "Guest":
+ return []
+
assert (not fields) or isinstance(
fields, (list, tuple, set)
), "`fields` parameters must be a list, tuple, set, or None"
@@ -1075,7 +1159,11 @@ def _get_events(
item_name = item if isinstance(item, str) else item.name
extra_conditions.append(IB.item == item_name)
if user:
- extra_conditions.append(IB.user == user)
+ customers, _ = get_customers_suppliers("Customer", user)
+ cond = IB.user == user
+ if customers:
+ cond |= IB.party_name.isin(customers)
+ extra_conditions.append(cond)
query = (
frappe.qb.get_query("Item Booking", filters=filters)
@@ -1188,23 +1276,12 @@ def _get_booking_subscriptions_between(
else:
all_filters.append(item_field.isnotnull()) # Must be a booking subscription
- # if user:
- # Contact = frappe.qb.DocType("Contact")
- # DynamicLink = frappe.qb.DocType("Dynamic Link")
- # query_for_customers = (
- # frappe.qb.select(DynamicLink.link_name)
- # .from_(Contact)
- # .join(DynamicLink)
- # .on(
- # (Contact.name == DynamicLink.parent)
- # (DynamicLink.parenttype == "Contact")
- # & (DynamicLink.link_doctype == "Customer")
- # & (Contact.user == user)
- # )
- # )
- # all_customers: set = { res[0] for res in query_for_customers.run() }
- # customer_field: Field = Subscription.customer
- # all_filters.append(customer_field.isin(all_customers))
+ if user:
+ customers, _ = get_customers_suppliers("Customer", user)
+ if customers:
+ all_filters.append(Subscription.customer.isin(customers))
+ else:
+ return []
all_fields.extend(
(
@@ -1789,6 +1866,7 @@ def move_booking_with_event(doc, method):
doc = frappe.get_doc("Item Booking", booking.name)
doc.starts_on = add_days(booking.starts_on, days)
doc.ends_on = add_days(booking.ends_on, days)
+ doc.flags.ignore_permissions = True
doc.save()
diff --git a/erpnext/venue/doctype/item_booking/item_booking_list.js b/erpnext/venue/doctype/item_booking/item_booking_list.js
index eeed65eccdc270ca4be0eb56c5f081e7d63e87e9..c168b705bac6bccb9b336b6b907b6a17a4f27e9f 100644
--- a/erpnext/venue/doctype/item_booking/item_booking_list.js
+++ b/erpnext/venue/doctype/item_booking/item_booking_list.js
@@ -1,4 +1,5 @@
frappe.listview_settings['Item Booking'] = {
+ hide_name_column: true,
get_indicator: function(doc) {
if (doc.status == "Confirmed") {
return [__("Confirmed"), "green", "status,=,Confirmed"];
diff --git a/erpnext/venue/doctype/item_booking/test_item_booking.py b/erpnext/venue/doctype/item_booking/test_item_booking.py
index b36b81bd372367b7b4e03ce5f7405495f207f473..87a8ef88a7035e69122f36020be15af61b3c41bc 100644
--- a/erpnext/venue/doctype/item_booking/test_item_booking.py
+++ b/erpnext/venue/doctype/item_booking/test_item_booking.py
@@ -2,12 +2,13 @@
# See license.txt
from collections import Counter
+from contextlib import contextmanager
from datetime import date, datetime
from operator import itemgetter
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
-from frappe.utils import add_to_date, getdate
+from frappe.utils import add_to_date, get_datetime, getdate
from erpnext import get_default_company
from erpnext.venue.doctype.item_booking.item_booking import get_availabilities
@@ -440,6 +441,108 @@ class TestItemBooking(BaseTestWithBookableItem):
)
self.assertEqual(len(availabilities), 10) # 10 hours between 8:00-18:00, normal, after
+ @change_settings("Venue Settings", {"minute_uom": "Minute", "enable_simultaneous_booking": 1})
+ def test_booking_cancellation(self):
+ from erpnext.venue.doctype.item_booking.item_booking import cancel_appointment, end_booking_now
+
+ @contextmanager
+ def rollbacker():
+ frappe.db.savepoint("TestItemBooking_test_booking_cancellation")
+ frappe.set_user("test@example.com")
+ yield
+ frappe.set_user("Administrator")
+ frappe.db.rollback(save_point="TestItemBooking_test_booking_cancellation")
+
+ past1 = add_to_date(getdate(), days=-4, hours=7)
+ past2 = add_to_date(past1, hours=5)
+ present1 = add_to_date(get_datetime(), hours=-3)
+ present2 = add_to_date(get_datetime(), hours=3)
+ future1 = add_to_date(getdate(), days=4, hours=7)
+ future2 = add_to_date(future1, hours=5)
+
+ frappe.set_user("Administrator")
+ past = self.makeBookingWithAutocleanup(
+ self.ITEM_BOOKABLE_1.name, past1, past2, user="test@example.com"
+ )
+ present = self.makeBookingWithAutocleanup(
+ self.ITEM_BOOKABLE_1.name, present1, present2, user="test@example.com"
+ )
+ future = self.makeBookingWithAutocleanup(
+ self.ITEM_BOOKABLE_1.name, future1, future2, user="test@example.com"
+ )
+
+ with rollbacker():
+ self.assertRaises(frappe.ValidationError, end_booking_now, past.name)
+ end_booking_now(present.name)
+ self.assertRaises(frappe.ValidationError, end_booking_now, future.name)
+
+ with rollbacker():
+ frappe.set_user("test2@example.com")
+ self.assertRaises(frappe.PermissionError, end_booking_now, present.name)
+
+ with change_settings("Venue Settings", {"allow_event_cancellation": 0, "cancellation_delay": 0}):
+ with rollbacker():
+ self.assertRaises(frappe.ValidationError, cancel_appointment, past.name)
+ self.assertRaises(frappe.ValidationError, cancel_appointment, present.name)
+ self.assertRaises(frappe.ValidationError, cancel_appointment, future.name)
+
+ with change_settings("Venue Settings", {"allow_event_cancellation": 1, "cancellation_delay": 0}):
+ with rollbacker():
+ self.assertRaises(frappe.ValidationError, cancel_appointment, past.name)
+ self.assertRaises(frappe.ValidationError, cancel_appointment, present.name)
+ cancel_appointment(future.name)
+ with rollbacker():
+ frappe.set_user("test2@example.com")
+ self.assertRaises(frappe.PermissionError, cancel_appointment, future.name)
+
+ with change_settings(
+ "Venue Settings", {"allow_event_cancellation": 1, "cancellation_delay": 9999999999999}
+ ):
+ with rollbacker():
+ self.assertRaises(frappe.ValidationError, cancel_appointment, past.name)
+ self.assertRaises(frappe.ValidationError, cancel_appointment, present.name)
+ self.assertRaises(frappe.ValidationError, cancel_appointment, future.name)
+
+ frappe.set_user("Administrator")
+
+ @change_settings("Venue Settings", {"minute_uom": "Minute", "enable_simultaneous_booking": 1})
+ def test_booking_web_list(self):
+ from erpnext.venue.doctype.item_booking.item_booking import (
+ get_bookings_list,
+ get_bookings_list_for_map,
+ )
+
+ def check_results(user: str, n1: int, n2: int):
+ frappe.set_user(user)
+ res1 = get_bookings_list(
+ "Item Booking", "", {}, limit_start=0, limit_page_length=20, order_by=None
+ )
+ self.assertEqual(len(res1), n1)
+ res2 = get_bookings_list_for_map(getdate(), add_to_date(getdate(), days=7))
+ self.assertEqual(len(res2), n2)
+
+ frappe.db.delete("Item Booking", {}) # Clear all bookings
+ check_results("Administrator", 0, 0)
+ check_results("test@example.com", 0, 0)
+
+ frappe.set_user("Administrator")
+ dt_start = add_to_date(getdate(), days=4, hours=7)
+ dt_end = add_to_date(dt_start, hours=5)
+ # Can be seen in the web list, only by test@example.com
+ booking1 = self.makeBookingWithAutocleanup(
+ self.ITEM_BOOKABLE_1.name, dt_start, dt_end, user="test@example.com"
+ )
+ # Cannot be seen in the web list
+ booking2 = self.makeBookingWithAutocleanup(self.ITEM_BOOKABLE_1.name, dt_start, dt_end)
+
+ check_results("Administrator", 0, 0) # Even Administrator cannot see these results
+ check_results("Guest", 0, 0)
+ check_results("test@example.com", 1, 1)
+ check_results("test2@example.com", 0, 0)
+
+ frappe.set_user("Administrator")
+ frappe.db.rollback()
+
class TestItemBookingWithSubscription(BaseTestWithSubscriptionForBookableItem):
@change_settings("Venue Settings", {"minute_uom": "Minute", "enable_simultaneous_booking": 1})