Merge pull request #623 from frappe/develop

chore: Merge develop to main
This commit is contained in:
Shariq Ansari 2025-03-01 17:33:24 +05:30 committed by GitHub
commit cc50319941
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 455 additions and 57 deletions

View File

@ -15,12 +15,10 @@ def login():
frappe.local.response["location"] = frappe.local.response["redirect_to"]
def validate_reset_password(user):
def validate_reset_password(doc, event):
if frappe.conf.demo_username and frappe.session.user == frappe.conf.demo_username:
frappe.throw(
_("Password cannot be reset by Demo User {}").format(
frappe.bold(frappe.conf.demo_username)
),
_("Password cannot be reset by Demo User {}").format(frappe.bold(frappe.conf.demo_username)),
frappe.PermissionError,
)
@ -28,9 +26,6 @@ def validate_reset_password(user):
def validate_user(doc, event):
if frappe.conf.demo_username and frappe.session.user == frappe.conf.demo_username and doc.new_password:
frappe.throw(
_("Password cannot be reset by Demo User {}").format(
frappe.bold(frappe.conf.demo_username)
),
_("Password cannot be reset by Demo User {}").format(frappe.bold(frappe.conf.demo_username)),
frappe.PermissionError,
)

View File

@ -2,6 +2,7 @@ import json
import frappe
from frappe import _
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.model import no_value_fields
from frappe.model.document import get_controller
from frappe.utils import make_filter_tuple
@ -178,23 +179,39 @@ def get_doctype_fields_meta(DocField, doctype, allowed_fieldtypes, restricted_fi
@frappe.whitelist()
def get_quick_filters(doctype: str):
meta = frappe.get_meta(doctype)
fields = [field for field in meta.fields if field.in_standard_filter]
def get_quick_filters(doctype: str, cached: bool = True):
meta = frappe.get_meta(doctype, cached)
quick_filters = []
if global_settings := frappe.db.exists("CRM Global Settings", {"dt": doctype, "type": "Quick Filters"}):
_quick_filters = frappe.db.get_value("CRM Global Settings", global_settings, "json")
_quick_filters = json.loads(_quick_filters) or []
fields = []
for filter in _quick_filters:
if filter == "name":
fields.append({"label": "Name", "fieldname": "name", "fieldtype": "Data"})
else:
field = next((f for f in meta.fields if f.fieldname == filter), None)
if field:
fields.append(field)
else:
fields = [field for field in meta.fields if field.in_standard_filter]
for field in fields:
options = field.options
if field.fieldtype == "Select" and options and isinstance(options, str):
options = field.get("options")
if field.get("fieldtype") == "Select" and options and isinstance(options, str):
options = options.split("\n")
options = [{"label": option, "value": option} for option in options]
if not any([not option.get("value") for option in options]):
options.insert(0, {"label": "", "value": ""})
quick_filters.append(
{
"label": _(field.label),
"fieldname": field.fieldname,
"fieldtype": field.fieldtype,
"label": _(field.get("label")),
"fieldname": field.get("fieldname"),
"fieldtype": field.get("fieldtype"),
"options": options,
}
)
@ -205,6 +222,55 @@ def get_quick_filters(doctype: str):
return quick_filters
@frappe.whitelist()
def update_quick_filters(quick_filters: str, old_filters: str, doctype: str):
quick_filters = json.loads(quick_filters)
old_filters = json.loads(old_filters)
new_filters = [filter for filter in quick_filters if filter not in old_filters]
removed_filters = [filter for filter in old_filters if filter not in quick_filters]
# update or create global quick filter settings
create_update_global_settings(doctype, quick_filters)
# remove old filters
for filter in removed_filters:
update_in_standard_filter(filter, doctype, 0)
# add new filters
for filter in new_filters:
update_in_standard_filter(filter, doctype, 1)
def create_update_global_settings(doctype, quick_filters):
if global_settings := frappe.db.exists("CRM Global Settings", {"dt": doctype, "type": "Quick Filters"}):
frappe.db.set_value("CRM Global Settings", global_settings, "json", json.dumps(quick_filters))
else:
# create CRM Global Settings doc
doc = frappe.new_doc("CRM Global Settings")
doc.dt = doctype
doc.type = "Quick Filters"
doc.json = json.dumps(quick_filters)
doc.insert()
def update_in_standard_filter(fieldname, doctype, value):
if property_name := frappe.db.exists(
"Property Setter",
{"doc_type": doctype, "field_name": fieldname, "property": "in_standard_filter"},
):
frappe.db.set_value("Property Setter", property_name, "value", value)
else:
make_property_setter(
doctype,
fieldname,
"in_standard_filter",
value,
"Check",
validate_fields_for_doctype=False,
)
@frappe.whitelist()
def get_data(
doctype: str,
@ -382,7 +448,7 @@ def get_data(
all_count = frappe.get_list(
doctype,
filters=convert_filter_to_tuple(doctype, new_filters),
fields="count(*) as total_count"
fields="count(*) as total_count",
)[0].total_count
kc["all_count"] = all_count
@ -485,9 +551,9 @@ def get_data(
"page_length_count": page_length_count,
"is_default": is_default,
"views": get_views(doctype),
"total_count": frappe.get_list(
doctype, filters=filters, fields="count(*) as total_count"
)[0].total_count,
"total_count": frappe.get_list(doctype, filters=filters, fields="count(*) as total_count")[
0
].total_count,
"row_count": len(data),
"form_script": get_form_script(doctype),
"list_script": get_form_script(doctype, "List"),

View File

@ -0,0 +1,8 @@
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
// frappe.ui.form.on("CRM Global Settings", {
// refresh(frm) {
// },
// });

View File

@ -0,0 +1,74 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "format:{type}-{dt}",
"creation": "2025-02-28 14:37:10.002433",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"dt",
"column_break_kipp",
"type",
"section_break_vass",
"json"
],
"fields": [
{
"default": "DocType",
"fieldname": "dt",
"fieldtype": "Link",
"in_list_view": 1,
"label": "DocType",
"options": "DocType",
"reqd": 1
},
{
"fieldname": "column_break_kipp",
"fieldtype": "Column Break"
},
{
"fieldname": "type",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Type",
"options": "Quick Filters\nSidebar Items",
"reqd": 1
},
{
"fieldname": "section_break_vass",
"fieldtype": "Section Break"
},
{
"fieldname": "json",
"fieldtype": "JSON",
"label": "JSON"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-02-28 14:55:33.801215",
"modified_by": "Administrator",
"module": "FCRM",
"name": "CRM Global Settings",
"naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CRMGlobalSettings(Document):
pass

View File

@ -0,0 +1,30 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from frappe.tests import IntegrationTestCase, UnitTestCase
# On IntegrationTestCase, the doctype test records and all
# link-field test record dependencies are recursively loaded
# Use these module variables to add/remove to/from that list
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
class UnitTestCRMGlobalSettings(UnitTestCase):
"""
Unit tests for CRMGlobalSettings.
Use this class for testing individual functions and methods.
"""
pass
class IntegrationTestCRMGlobalSettings(IntegrationTestCase):
"""
Integration tests for CRMGlobalSettings.
Use this class for testing interactions between multiple components.
"""
pass

View File

@ -132,7 +132,6 @@ before_uninstall = "crm.uninstall.before_uninstall"
override_doctype_class = {
"Contact": "crm.overrides.contact.CustomContact",
"Email Template": "crm.overrides.email_template.CustomEmailTemplate",
"User": "crm.overrides.user.CustomUser",
}
# Document Events
@ -161,6 +160,7 @@ doc_events = {
},
"User": {
"before_validate": ["crm.api.demo.validate_user"],
"validate_reset_password": ["crm.api.demo.validate_reset_password"],
},
}

View File

@ -1,10 +0,0 @@
# import frappe
from frappe import _
from frappe.core.doctype.user.user import User
from crm.api.demo import validate_reset_password
class CustomUser(User):
def validate_reset_password(self):
# restrict demo user to reset password
validate_reset_password(self)

View File

@ -1,5 +1,10 @@
<template>
<Popover placement="right-start" class="flex w-full">
<Popover
placement="right-start"
trigger="hover"
:hoverDelay="0.1"
:leaveDelay="0.1"
>
<template #target="{ togglePopover }">
<button
:class="[
@ -19,19 +24,19 @@
</template>
<template #body>
<div
class="grid grid-cols-3 justify-between mx-3 p-2 min-w-40 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
class="flex w-fit mx-2 min-w-32 max-w-48 flex-col rounded-lg border border-outline-gray-2 bg-surface-white p-1.5 text-sm text-ink-gray-8 shadow-xl auto-fill-[100px]"
>
<div v-for="app in apps.data" :key="app.name">
<a
:href="app.route"
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-1 hover:bg-surface-gray-2"
>
<img class="size-8" :src="app.logo" />
<div class="text-sm text-ink-gray-7" @click="app.onClick">
{{ app.title }}
</div>
</a>
</div>
<a
:href="app.route"
v-for="app in apps.data"
key="name"
class="flex items-center gap-2 rounded p-1.5 hover:bg-surface-gray-2"
>
<img class="size-6" :src="app.logo" />
<span class="max-w-18 w-full truncate">
{{ app.title }}
</span>
</a>
</div>
</template>
</Popover>

View File

@ -94,7 +94,7 @@ import { usersStore } from '@/stores/users'
import { TextEditorBubbleMenu, TextEditor, FileUploader } from 'frappe-ui'
import { capture } from '@/telemetry'
import { EditorContent } from '@tiptap/vue-3'
import { ref, computed, defineModel } from 'vue'
import { ref, computed } from 'vue'
const props = defineProps({
placeholder: {

View File

@ -183,7 +183,7 @@ import { capture } from '@/telemetry'
import { validateEmail } from '@/utils'
import Paragraph from '@tiptap/extension-paragraph'
import { EditorContent } from '@tiptap/vue-3'
import { ref, computed, defineModel, nextTick } from 'vue'
import { ref, computed, nextTick } from 'vue'
const props = defineProps({
placeholder: {

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 10V2M8 2L5.33333 4.66663M8 2L10.6667 4.66663M11.8571 6.99996H13C13.5523 6.99996 14 7.44767 14 7.99996V13C14 13.5522 13.5523 14 13 14H3C2.44772 14 2 13.5522 2 13V7.99996C2 7.44767 2.44772 6.99996 3 6.99996H4.14286"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>

View File

@ -0,0 +1,20 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-list-filter-plus"
>
<path d="M10 18h4" />
<path d="M11 6H3" />
<path d="M15 6h6" />
<path d="M18 9V3" />
<path d="M7 12h8" />
</svg>
</template>

View File

@ -46,7 +46,7 @@ import { Dropdown, Tooltip } from 'frappe-ui'
import { timeAgo, formatDate, formatTime } from '@/utils'
import { statusesStore } from '@/stores/statuses'
import { capture } from '@/telemetry'
import { computed, defineModel } from 'vue'
import { computed } from 'vue'
const data = defineModel()
const emit = defineEmits(['updateField'])

View File

@ -58,6 +58,79 @@
</div>
</div>
</div>
<div
v-else-if="customizeQuickFilter"
class="flex items-center justify-between gap-2 p-5"
>
<div class="flex flex-1 items-center overflow-hidden pl-1 gap-2">
<FadedScrollableDiv
class="flex items-center gap-2 overflow-x-auto -ml-1"
orientation="horizontal"
>
<Draggable
class="flex gap-2"
:list="newQuickFilters"
group="filters"
item-key="fieldname"
>
<template #item="{ element: filter }">
<Button class="group whitespace-nowrap cursor-grab">
<template #default>
<Tooltip :text="filter.fieldname">
<span>{{ filter.label }}</span>
</Tooltip>
</template>
<template #suffix>
<FeatherIcon
class="h-3.5 cursor-pointer group-hover:flex hidden"
name="x"
@click.stop="removeQuickFilter(filter)"
/>
</template>
</Button>
</template>
</Draggable>
</FadedScrollableDiv>
<Autocomplete
value=""
:options="quickFilterOptions"
@change="(e) => addQuickFilter(e)"
>
<template #target="{ togglePopover }">
<Button
class="whitespace-nowrap mr-2"
variant="ghost"
@click="togglePopover()"
:label="__('Add filter')"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value" :hover-delay="1">
<div class="flex-1 truncate text-ink-gray-7">
{{ option.label }}
</div>
</Tooltip>
</template>
</Autocomplete>
</div>
<div class="-ml-2 h-[70%] border-l" />
<div class="flex gap-1">
<Button
:label="__('Save')"
:loading="updateQuickFilters.loading"
@click="saveQuickFilters"
/>
<Button @click="customizeQuickFilter = false">
<template #icon>
<FeatherIcon name="x" class="h-4 w-4" />
</template>
</Button>
</div>
</div>
<div v-else class="flex items-center justify-between gap-2 px-5 py-4">
<FadedScrollableDiv
class="flex flex-1 items-center overflow-x-auto -ml-1"
@ -120,9 +193,7 @@
@update="(isDefault) => updateColumns(isDefault)"
/>
<Dropdown
v-if="
!options.hideColumnsButton && route.params.viewType !== 'kanban'
"
v-if="route.params.viewType !== 'kanban'"
:options="[
{
group: __('Options'),
@ -130,9 +201,15 @@
items: [
{
label: __('Export'),
icon: () =>
h(FeatherIcon, { name: 'download', class: 'h-4 w-4' }),
icon: () => h(ExportIcon, { class: 'h-4 w-4' }),
onClick: () => (showExportDialog = true),
condition: () => !options.hideColumnsButton,
},
{
label: __('Customize quick filters'),
icon: () => h(QuickFilterIcon, { class: 'h-4 w-4' }),
onClick: () => showCustomizeQuickFilter(),
condition: () => isManager(),
},
],
},
@ -218,7 +295,10 @@ import DuplicateIcon from '@/components/Icons/DuplicateIcon.vue'
import CheckIcon from '@/components/Icons/CheckIcon.vue'
import PinIcon from '@/components/Icons/PinIcon.vue'
import UnpinIcon from '@/components/Icons/UnpinIcon.vue'
import ExportIcon from '@/components/Icons/ExportIcon.vue'
import QuickFilterIcon from '@/components/Icons/QuickFilterIcon.vue'
import ViewModal from '@/components/Modals/ViewModal.vue'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import SortBy from '@/components/SortBy.vue'
import Filter from '@/components/Filter.vue'
import GroupBy from '@/components/GroupBy.vue'
@ -229,8 +309,10 @@ import { getSettings } from '@/stores/settings'
import { globalStore } from '@/stores/global'
import { viewsStore } from '@/stores/views'
import { usersStore } from '@/stores/users'
import { isEmoji } from '@/utils'
import { getMeta } from '@/stores/meta'
import { isEmoji, createToast } from '@/utils'
import {
Tooltip,
createResource,
Dropdown,
call,
@ -241,6 +323,7 @@ import { computed, ref, onMounted, watch, h, markRaw } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useDebounceFn } from '@vueuse/core'
import { isMobileView } from '@/composables/settings'
import Draggable from 'vuedraggable'
import _ from 'lodash'
const props = defineProps({
@ -594,11 +677,102 @@ const viewsDropdownOptions = computed(() => {
return _views
})
const quickFilterList = computed(() => {
let filters = [{ fieldname: 'name', fieldtype: 'Data', label: __('ID') }]
if (quickFilters.data) {
filters.push(...quickFilters.data)
const { getFields } = getMeta(props.doctype)
const customizeQuickFilter = ref(false)
function showCustomizeQuickFilter() {
customizeQuickFilter.value = true
setupNewQuickFilters(quickFilters.data)
}
const newQuickFilters = ref([])
function addQuickFilter(f) {
if (!newQuickFilters.value.some((filter) => filter.fieldname === f.value)) {
newQuickFilters.value.push({
label: f.label,
fieldname: f.value,
fieldtype: f.fieldtype,
})
}
}
function removeQuickFilter(f) {
newQuickFilters.value = newQuickFilters.value.filter(
(filter) => filter.fieldname !== f.fieldname,
)
}
const updateQuickFilters = createResource({
url: 'crm.api.doc.update_quick_filters',
onSuccess() {
customizeQuickFilter.value = false
quickFilters.update({ params: { doctype: props.doctype, cached: false } })
quickFilters.reload()
createToast({
title: __('Quick Filters updated successfully'),
icon: 'check',
iconClasses: 'text-ink-green-3',
})
},
})
function saveQuickFilters() {
let new_filters =
newQuickFilters.value?.map((filter) => filter.fieldname) || []
let old_filters = quickFilters.data?.map((filter) => filter.fieldname) || []
updateQuickFilters.update({
params: {
quick_filters: JSON.stringify(new_filters),
old_filters: JSON.stringify(old_filters),
doctype: props.doctype,
},
})
updateQuickFilters.fetch()
}
const quickFilterOptions = computed(() => {
let fields = getFields()
if (!fields) return []
let restrictedFieldtypes = [
'Tab Break',
'Section Break',
'Column Break',
'Table',
'Table MultiSelect',
'HTML',
'Button',
'Image',
'Fold',
'Heading',
]
let options = fields
.filter((f) => f.label && !restrictedFieldtypes.includes(f.fieldtype))
.map((field) => ({
label: field.label,
value: field.fieldname,
fieldtype: field.fieldtype,
}))
if (!options.some((f) => f.fieldname === 'name')) {
options.push({
label: __('Name'),
value: 'name',
fieldtype: 'Data',
})
}
return options
})
const quickFilterList = computed(() => {
let filters = quickFilters.data || []
filters.forEach((filter) => {
filter['value'] = filter.fieldtype == 'Check' ? false : ''
@ -630,8 +804,19 @@ const quickFilters = createResource({
params: { doctype: props.doctype },
cache: ['Quick Filters', props.doctype],
auto: true,
onSuccess(filters) {
setupNewQuickFilters(filters)
},
})
function setupNewQuickFilters(filters) {
newQuickFilters.value = filters.map((f) => ({
label: f.label,
fieldname: f.fieldname,
fieldtype: f.fieldtype,
}))
}
function applyQuickFilter(filter, value) {
let filters = { ...list.value.params.filters }
let field = filter.fieldname