Просмотр исходного кода

Merge pull request #8488 from hrwX/global_tags

feat(Desk): Global Tags
version-14
Faris Ansari 5 лет назад
committed by GitHub
Родитель
Сommit
f926a2aa48
Не найден GPG ключ соответствующий данной подписи Идентификатор GPG ключа: 4AEE18F83AFDEB23
38 измененных файлов: 616 добавлений и 490 удалений
  1. +0
    -58
      frappe/core/doctype/tag/tag.json
  2. +0
    -11
      frappe/core/doctype/tag/tag.py
  3. +0
    -9
      frappe/core/doctype/tag_category/tag_category.js
  4. +0
    -147
      frappe/core/doctype/tag_category/tag_category.json
  5. +0
    -12
      frappe/core/doctype/tag_category/test_tag_category.py
  6. +0
    -0
      frappe/core/doctype/tag_doc_category/__init__.py
  7. +0
    -58
      frappe/core/doctype/tag_doc_category/tag_doc_category.json
  8. +0
    -10
      frappe/core/doctype/tag_doc_category/tag_doc_category.py
  9. +20
    -0
      frappe/database/database.py
  10. +0
    -0
      frappe/desk/doctype/tag/__init__.py
  11. +8
    -0
      frappe/desk/doctype/tag/tag.js
  12. +49
    -0
      frappe/desk/doctype/tag/tag.json
  13. +179
    -0
      frappe/desk/doctype/tag/tag.py
  14. +10
    -0
      frappe/desk/doctype/tag/test_tag.py
  15. +0
    -0
      frappe/desk/doctype/tag_link/__init__.py
  16. +8
    -0
      frappe/desk/doctype/tag_link/tag_link.js
  17. +70
    -0
      frappe/desk/doctype/tag_link/tag_link.json
  18. +3
    -3
      frappe/desk/doctype/tag_link/tag_link.py
  19. +10
    -0
      frappe/desk/doctype/tag_link/test_tag_link.py
  20. +10
    -1
      frappe/desk/form/load.py
  21. +10
    -6
      frappe/desk/reportview.py
  22. +0
    -127
      frappe/desk/tags.py
  23. +4
    -1
      frappe/model/delete_doc.py
  24. +1
    -0
      frappe/patches.txt
  25. +31
    -0
      frappe/patches/v12_0/setup_tags.py
  26. +1
    -0
      frappe/public/build.json
  27. +6
    -0
      frappe/public/js/frappe/desk.js
  28. +2
    -3
      frappe/public/js/frappe/form/sidebar/form_sidebar.js
  29. +5
    -26
      frappe/public/js/frappe/list/list_sidebar.js
  30. +1
    -1
      frappe/public/js/frappe/list/list_view.js
  31. +7
    -0
      frappe/public/js/frappe/ui/filters/filter.js
  32. +5
    -5
      frappe/public/js/frappe/ui/tag_editor.js
  33. +0
    -1
      frappe/public/js/frappe/ui/tags.js
  34. +11
    -0
      frappe/public/js/frappe/ui/toolbar/awesome_bar.js
  35. +47
    -3
      frappe/public/js/frappe/ui/toolbar/search.js
  36. +10
    -4
      frappe/public/js/frappe/ui/toolbar/search_header.html
  37. +108
    -0
      frappe/public/js/frappe/ui/toolbar/tag_utils.js
  38. +0
    -4
      frappe/utils/global_search.py

+ 0
- 58
frappe/core/doctype/tag/tag.json Просмотреть файл

@@ -1,58 +0,0 @@
{
"allow_copy": 0,
"allow_import": 0,
"allow_rename": 0,
"autoname": "",
"creation": "2016-05-25 09:43:44.767581",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"fields": [
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"fieldname": "tag_name",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 1,
"label": "Tags",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
}
],
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"in_create": 0,

"is_submittable": 0,
"issingle": 0,
"istable": 1,
"max_attachments": 0,
"modified": "2016-05-31 08:29:01.773065",
"modified_by": "Administrator",
"module": "Core",
"name": "Tag",
"name_case": "",
"owner": "Administrator",
"permissions": [],
"read_only": 0,
"read_only_onload": 0,
"sort_field": "modified",
"sort_order": "DESC"
}

+ 0
- 11
frappe/core/doctype/tag/tag.py Просмотреть файл

@@ -1,11 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
# For license information, please see license.txt

from __future__ import unicode_literals
import frappe
from frappe.model.document import Document

class Tag(Document):
def validate(self):
self.tag_name = self.tag_name.title()

+ 0
- 9
frappe/core/doctype/tag_category/tag_category.js Просмотреть файл

@@ -1,9 +0,0 @@
// Copyright (c) 2016, Frappe Technologies and contributors
// For license information, please see license.txt
frappe.ui.form.on('Tag', {
tag_name:function(frm){
for (var i = 0 ;i<frm.doc.tags.length;i++){
frm.doc.tags[i].tag_name = toTitle(frm.doc.tags[i].tag_name)
}
}
});

+ 0
- 147
frappe/core/doctype/tag_category/tag_category.json Просмотреть файл

@@ -1,147 +0,0 @@
{
"allow_copy": 0,
"allow_import": 1,
"allow_rename": 0,
"autoname": "field:category_name",
"beta": 0,
"creation": "2016-05-25 09:49:07.125394",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 0,
"fields": [
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "category_name",
"fieldtype": "Data",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Category Name",
"length": 0,
"no_copy": 0,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "tags",
"fieldtype": "Table",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Tags",
"length": 0,
"no_copy": 0,
"options": "Tag",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "tagdocs",
"fieldtype": "Table",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Doctypes",
"length": 0,
"no_copy": 0,
"options": "Tag Doc Category",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
}
],
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"image_view": 0,
"in_create": 0,

"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
"modified": "2016-12-29 14:40:37.489085",
"modified_by": "Administrator",
"module": "Core",
"name": "Tag Category",
"name_case": "Title Case",
"owner": "Administrator",
"permissions": [
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 0,
"export": 1,
"if_owner": 0,
"import": 1,
"is_custom": 0,
"permlevel": 0,
"print": 0,
"read": 1,
"report": 0,
"role": "System Manager",
"set_user_permissions": 0,
"share": 0,
"submit": 0,
"write": 1
}
],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0
}

+ 0
- 12
frappe/core/doctype/tag_category/test_tag_category.py Просмотреть файл

@@ -1,12 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and Contributors
# See license.txt
from __future__ import unicode_literals

import frappe
import unittest

# test_records = frappe.get_test_records('Tag Categories')

class TestTagCategories(unittest.TestCase):
pass

+ 0
- 0
frappe/core/doctype/tag_doc_category/__init__.py Просмотреть файл


+ 0
- 58
frappe/core/doctype/tag_doc_category/tag_doc_category.json Просмотреть файл

@@ -1,58 +0,0 @@
{
"allow_copy": 0,
"allow_import": 0,
"allow_rename": 0,
"creation": "2016-05-25 13:09:20.996154",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"fields": [
{
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"fieldname": "tagdoc",
"fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_list_view": 1,
"label": "Doctype to Assign Tags",
"length": 0,
"no_copy": 0,
"options": "DocType",
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0
}
],
"hide_heading": 0,
"hide_toolbar": 0,
"idx": 0,
"in_create": 0,

"is_submittable": 0,
"issingle": 0,
"istable": 1,
"max_attachments": 0,
"modified": "2016-05-30 15:04:45.454688",
"modified_by": "Administrator",
"module": "Core",
"name": "Tag Doc Category",
"name_case": "",
"owner": "Administrator",
"permissions": [],
"read_only": 0,
"read_only_onload": 0,
"sort_field": "modified",
"sort_order": "DESC"
}

+ 0
- 10
frappe/core/doctype/tag_doc_category/tag_doc_category.py Просмотреть файл

@@ -1,10 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
# For license information, please see license.txt

from __future__ import unicode_literals
import frappe
from frappe.model.document import Document

class TagDocCategory(Document):
pass

+ 20
- 0
frappe/database/database.py Просмотреть файл

@@ -961,6 +961,26 @@ class Database(object):
frappe.flags.touched_tables = set()
frappe.flags.touched_tables.update(tables)

def bulk_insert(self, doctype, fields, values):
"""
Insert multiple records at a time

:param doctype: Doctype name
:param fields: list of fields
:params values: list of list of values
"""
insert_list = []
fields = ", ".join(["`"+field+"`" for field in fields])

for idx, value in enumerate(values):
insert_list.append(tuple(value))
if idx and (idx%10000 == 0 or idx < len(values)-1):
self.sql("""INSERT INTO `tab{doctype}` ({fields}) VALUES {values}""".format(
doctype=doctype,
fields=fields,
values=", ".join(['%s'] * len(insert_list))
), tuple(insert_list))
insert_list = []

def enqueue_jobs_after_commit():
if frappe.flags.enqueue_after_commit and len(frappe.flags.enqueue_after_commit) > 0:


frappe/core/doctype/tag/__init__.py → frappe/desk/doctype/tag/__init__.py Просмотреть файл


+ 8
- 0
frappe/desk/doctype/tag/tag.js Просмотреть файл

@@ -0,0 +1,8 @@
// Copyright (c) 2019, Frappe Technologies and contributors
// For license information, please see license.txt

frappe.ui.form.on('Tag', {
// refresh: function(frm) {

// }
});

+ 49
- 0
frappe/desk/doctype/tag/tag.json Просмотреть файл

@@ -0,0 +1,49 @@
{
"autoname": "Prompt",
"creation": "2016-05-25 09:43:44.767581",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"description"
],
"fields": [
{
"fieldname": "description",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Description"
}
],
"modified": "2019-09-25 17:47:41.712237",
"modified_by": "Administrator",
"module": "Desk",
"name": "Tag",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
},
{
"create": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "All",
"share": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC"
}

+ 179
- 0
frappe/desk/doctype/tag/tag.py Просмотреть файл

@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and contributors
# For license information, please see license.txt

from __future__ import unicode_literals
import frappe
from frappe.model.document import Document

class Tag(Document):
pass

def check_user_tags(dt):
"if the user does not have a tags column, then it creates one"
try:
frappe.db.sql("select `_user_tags` from `tab%s` limit 1" % dt)
except Exception as e:
if frappe.db.is_column_missing(e):
DocTags(dt).setup()

@frappe.whitelist()
def add_tag(tag, dt, dn, color=None):
"adds a new tag to a record, and creates the Tag master"
DocTags(dt).add(dn, tag)

return tag

@frappe.whitelist()
def remove_tag(tag, dt, dn):
"removes tag from the record"
DocTags(dt).remove(dn, tag)

@frappe.whitelist()
def get_tagged_docs(doctype, tag):
frappe.has_permission(doctype, throw=True)

return frappe.db.sql("""SELECT name
FROM `tab{0}`
WHERE _user_tags LIKE '%{1}%'""".format(doctype, tag))

@frappe.whitelist()
def get_tags(doctype, txt):
tag = frappe.get_list("Tag", filters=[["name", "like", "%{}%".format(txt)]])
tags = [t.name for t in tag]

return sorted(filter(lambda t: t and txt.lower() in t.lower(), list(set(tags))))

class DocTags:
"""Tags for a particular doctype"""
def __init__(self, dt):
self.dt = dt

def get_tag_fields(self):
"""returns tag_fields property"""
return frappe.db.get_value('DocType', self.dt, 'tag_fields')

def get_tags(self, dn):
"""returns tag for a particular item"""
return (frappe.db.get_value(self.dt, dn, '_user_tags', ignore=1) or '').strip()

def add(self, dn, tag):
"""add a new user tag"""
tl = self.get_tags(dn).split(',')
if not tag in tl:
tl.append(tag)
if not frappe.db.exists("Tag", tag):
frappe.get_doc({"doctype": "Tag", "name": tag}).insert(ignore_permissions=True)
self.update(dn, tl)

def remove(self, dn, tag):
"""remove a user tag"""
tl = self.get_tags(dn).split(',')
self.update(dn, filter(lambda x:x.lower()!=tag.lower(), tl))

def remove_all(self, dn):
"""remove all user tags (call before delete)"""
self.update(dn, [])

def update(self, dn, tl):
"""updates the _user_tag column in the table"""

if not tl:
tags = ''
else:
tl = list(set(filter(lambda x: x, tl)))
tags = ',' + ','.join(tl)
try:
frappe.db.sql("update `tab%s` set _user_tags=%s where name=%s" % \
(self.dt,'%s','%s'), (tags , dn))
doc= frappe.get_doc(self.dt, dn)
update_tags(doc, tags)
except Exception as e:
if frappe.db.is_column_missing(e):
if not tags:
# no tags, nothing to do
return

self.setup()
self.update(dn, tl)
else: raise

def setup(self):
"""adds the _user_tags column if not exists"""
from frappe.database.schema import add_column
add_column(self.dt, "_user_tags", "Data")

def delete_tags_for_document(doc):
"""
Delete the Tag Link entry of a document that has
been deleted
:param doc: Deleted document
"""
if not frappe.db.table_exists("Tag Link"):
return

frappe.db.sql("""DELETE FROM `tabTag Link` WHERE `document_type`=%s AND `document_name`=%s""", (doc.doctype, doc.name))

def update_tags(doc, tags):
"""
Adds tags for documents
:param doc: Document to be added to global tags
"""

new_tags = list(set([tag.strip() for tag in tags.split(",") if tag]))

for tag in new_tags:
if not frappe.db.exists("Tag Link", {"parenttype": doc.doctype, "parent": doc.name, "tag": tag}):
frappe.get_doc({
"doctype": "Tag Link",
"document_type": doc.doctype,
"document_name": doc.name,
"parenttype": doc.doctype,
"parent": doc.name,
"title": doc.get_title() or '',
"tag": tag
}).insert(ignore_permissions=True)

existing_tags = [tag.tag for tag in frappe.get_list("Tag Link", filters={
"document_type": doc.doctype,
"document_name": doc.name
}, fields=["tag"])]

deleted_tags = get_deleted_tags(new_tags, existing_tags)

if deleted_tags:
for tag in deleted_tags:
delete_tag_for_document(doc.doctype, doc.name, tag)

def get_deleted_tags(new_tags, existing_tags):

return list(set(existing_tags) - set(new_tags))

def delete_tag_for_document(dt, dn, tag):
frappe.db.sql("""DELETE FROM `tabTag Link` WHERE `document_type`=%s AND `document_name`=%s AND tag=%s""", (dt, dn, tag))

@frappe.whitelist()
def get_documents_for_tag(tag):
"""
Search for given text in Tag Link
:param tag: tag to be searched
"""
# remove hastag `#` from tag
tag = tag[1:]
results = []

result = frappe.get_list("Tag Link", filters={"tag": tag}, fields=["document_type", "document_name", "title", "tag"])

for res in result:
results.append({
"doctype": res.document_type,
"name": res.document_name,
"content": res.title
})

print(results)
return results

@frappe.whitelist()
def get_tags_list_for_awesomebar():
return [t.name for t in frappe.get_list("Tag")]

+ 10
- 0
frappe/desk/doctype/tag/test_tag.py Просмотреть файл

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
# See license.txt
from __future__ import unicode_literals

# import frappe
import unittest

class TestTag(unittest.TestCase):
pass

frappe/core/doctype/tag_category/__init__.py → frappe/desk/doctype/tag_link/__init__.py Просмотреть файл


+ 8
- 0
frappe/desk/doctype/tag_link/tag_link.js Просмотреть файл

@@ -0,0 +1,8 @@
// Copyright (c) 2019, Frappe Technologies and contributors
// For license information, please see license.txt

frappe.ui.form.on('Tag Link', {
// refresh: function(frm) {

// }
});

+ 70
- 0
frappe/desk/doctype/tag_link/tag_link.json Просмотреть файл

@@ -0,0 +1,70 @@
{
"creation": "2019-09-24 13:25:36.435685",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"document_type",
"document_name",
"tag",
"title"
],
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Document Title",
"read_only": 1
},
{
"fieldname": "tag",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Document Tag",
"options": "Tag",
"read_only": 1
},
{
"fieldname": "document_type",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Document Type",
"options": "DocType",
"read_only": 1
},
{
"fieldname": "document_name",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Document Name",
"options": "document_type",
"read_only": 1
}
],
"modified": "2019-10-03 16:42:35.932409",
"modified_by": "Administrator",
"module": "Desk",
"name": "Tag Link",
"owner": "Administrator",
"permissions": [
{
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"write": 1
}
],
"read_only": 1,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}

frappe/core/doctype/tag_category/tag_category.py → frappe/desk/doctype/tag_link/tag_link.py Просмотреть файл

@@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies and contributors
# Copyright (c) 2019, Frappe Technologies and contributors
# For license information, please see license.txt

from __future__ import unicode_literals
import frappe
# import frappe
from frappe.model.document import Document

class TagCategory(Document):
class TagLink(Document):
pass

+ 10
- 0
frappe/desk/doctype/tag_link/test_tag_link.py Просмотреть файл

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2019, Frappe Technologies and Contributors
# See license.txt
from __future__ import unicode_literals

# import frappe
import unittest

class TestTagLink(unittest.TestCase):
pass

+ 10
- 1
frappe/desk/form/load.py Просмотреть файл

@@ -100,7 +100,8 @@ def get_docinfo(doc=None, doctype=None, name=None):
"views": get_view_logs(doc.doctype, doc.name),
"energy_point_logs": get_point_logs(doc.doctype, doc.name),
"milestones": get_milestones(doc.doctype, doc.name),
"is_document_followed": is_document_followed(doc.doctype, doc.name, frappe.session.user)
"is_document_followed": is_document_followed(doc.doctype, doc.name, frappe.session.user),
"tags": get_tags(doc.doctype, doc.name)
}

def get_milestones(doctype, name):
@@ -255,3 +256,11 @@ def get_view_logs(doctype, docname):
if view_logs:
logs = view_logs
return logs

def get_tags(doctype, name):
tags = [tag.tag for tag in frappe.get_all("Tag Link", filters={
"document_type": doctype,
"document_name": name
}, fields=["tag"])]

return ",".join([tag for tag in tags])

+ 10
- 6
frappe/desk/reportview.py Просмотреть файл

@@ -261,13 +261,17 @@ def delete_bulk(doctype, items):
@frappe.whitelist()
@frappe.read_only()
def get_sidebar_stats(stats, doctype, filters=[]):
cat_tags = frappe.db.sql("""select `tag`.parent as `category`, `tag`.tag_name as `tag`
from `tabTag Doc Category` as `docCat`
INNER JOIN `tabTag` as `tag` on `tag`.parent = `docCat`.parent
where `docCat`.tagdoc=%s
ORDER BY `tag`.parent asc, `tag`.idx""", doctype, as_dict=1)

return {"defined_cat":cat_tags, "stats":get_stats(stats, doctype, filters)}
if not frappe.cache().hget("tags_count", doctype):
tags = [tag.name for tag in frappe.get_list("Tag")]
_user_tags = []
for tag in tags:
count = frappe.db.count("Tag Link", filters={"document_type": doctype, "tag": tag})
if count > 0:
_user_tags.append([tag, count])
frappe.cache().hset("tags_count", doctype, _user_tags)

return {"stats": {"_user_tags": frappe.cache().hget("tags_count", doctype)}}

@frappe.whitelist()
@frappe.read_only()


+ 0
- 127
frappe/desk/tags.py Просмотреть файл

@@ -1,127 +0,0 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt

from __future__ import unicode_literals, print_function
import json
"""
Server side functions for tagging.

- Tags can be added to any record (doctype, name) in the system.
- Items are filtered by tags
- Top tags are shown in the sidebar (?)
- Tags are also identified by the tag_fields property of the DocType

Discussion:

Tags are shown in the docbrowser and ideally where-ever items are searched.
There should also be statistics available for tags (like top tags etc)


Design:

- free tags (user_tags) are stored in __user_tags
- doctype tags are set in tag_fields property of the doctype
- top tags merges the tags from both the lists (only refreshes once an hour (max))

"""

import frappe
from frappe.utils.global_search import update_global_search

def check_user_tags(dt):
"if the user does not have a tags column, then it creates one"
try:
frappe.db.sql("select `_user_tags` from `tab%s` limit 1" % dt)
except Exception as e:
if frappe.db.is_column_missing(e):
DocTags(dt).setup()

@frappe.whitelist()
def add_tag(tag, dt, dn, color=None):
"adds a new tag to a record, and creates the Tag master"
DocTags(dt).add(dn, tag)

return tag

@frappe.whitelist()
def remove_tag(tag, dt, dn):
"removes tag from the record"
DocTags(dt).remove(dn, tag)

@frappe.whitelist()
def get_tagged_docs(doctype, tag):
frappe.has_permission(doctype, throw=True)

return frappe.db.sql("""SELECT name
FROM `tab{0}`
WHERE _user_tags LIKE '%{1}%'""".format(doctype, tag))

@frappe.whitelist()
def get_tags(doctype, txt, cat_tags):
tags = json.loads(cat_tags)
try:
for _user_tags in frappe.db.sql_list("""select DISTINCT `_user_tags`
from `tab{0}`
where _user_tags like {1}
limit 50""".format(doctype, frappe.db.escape('%' + txt + '%'))):
tags.extend(_user_tags[1:].split(","))
except Exception as e:
if not frappe.db.is_column_missing(e): raise
return sorted(filter(lambda t: t and txt.lower() in t.lower(), list(set(tags))))

class DocTags:
"""Tags for a particular doctype"""
def __init__(self, dt):
self.dt = dt

def get_tag_fields(self):
"""returns tag_fields property"""
return frappe.db.get_value('DocType', self.dt, 'tag_fields')

def get_tags(self, dn):
"""returns tag for a particular item"""
return (frappe.db.get_value(self.dt, dn, '_user_tags', ignore=1) or '').strip()

def add(self, dn, tag):
"""add a new user tag"""
tl = self.get_tags(dn).split(',')
if not tag in tl:
tl.append(tag)
self.update(dn, tl)

def remove(self, dn, tag):
"""remove a user tag"""
tl = self.get_tags(dn).split(',')
self.update(dn, filter(lambda x:x.lower()!=tag.lower(), tl))

def remove_all(self, dn):
"""remove all user tags (call before delete)"""
self.update(dn, [])

def update(self, dn, tl):
"""updates the _user_tag column in the table"""

if not tl:
tags = ''
else:
tl = list(set(filter(lambda x: x, tl)))
tags = ',' + ','.join(tl)
try:
frappe.db.sql("update `tab%s` set _user_tags=%s where name=%s" % \
(self.dt,'%s','%s'), (tags , dn))
doc= frappe.get_doc(self.dt, dn)
update_global_search(doc)
except Exception as e:
if frappe.db.is_column_missing(e):
if not tags:
# no tags, nothing to do
return

self.setup()
self.update(dn, tl)
else: raise

def setup(self):
"""adds the _user_tags column if not exists"""
from frappe.database.schema import add_column
add_column(self.dt, "_user_tags", "Data")

+ 4
- 1
frappe/model/delete_doc.py Просмотреть файл

@@ -16,10 +16,11 @@ from frappe.core.doctype.file.file import remove_all
from frappe.utils.password import delete_all_passwords_for
from frappe.model.naming import revert_series_if_last
from frappe.utils.global_search import delete_for_document
from frappe.desk.doctype.tag.tag import delete_tags_for_document
from frappe.exceptions import FileNotFoundError


doctypes_to_skip = ("Communication", "ToDo", "DocShare", "Email Unsubscribe", "Activity Log", "File", "Version", "Document Follow", "Comment" , "View Log")
doctypes_to_skip = ("Communication", "ToDo", "DocShare", "Email Unsubscribe", "Activity Log", "File", "Version", "Document Follow", "Comment" , "View Log", "Tag Link")

def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reload=False,
ignore_permissions=False, flags=None, ignore_on_trash=False, ignore_missing=True):
@@ -116,6 +117,8 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa

# delete global search entry
delete_for_document(doc)
# delete tag link entry
delete_tags_for_document(doc)

if doc and not for_reload:
add_to_deleted_document(doc)


+ 1
- 0
frappe/patches.txt Просмотреть файл

@@ -252,3 +252,4 @@ frappe.patches.v12_0.move_email_and_phone_to_child_table
frappe.patches.v12_0.delete_duplicate_indexes
frappe.patches.v12_0.set_default_incoming_email_port
frappe.patches.v12_0.update_global_search
frappe.patches.v12_0.setup_tags

+ 31
- 0
frappe/patches/v12_0/setup_tags.py Просмотреть файл

@@ -0,0 +1,31 @@
import frappe

def execute():
frappe.delete_doc_if_exists("DocType", "Tag Category")
frappe.delete_doc_if_exists("DocType", "Tag Doc Category")

frappe.reload_doc("desk", "doctype", "tag")
frappe.reload_doc("desk", "doctype", "tag_link")

tag_list = []
tag_links = []
time = frappe.utils.get_datetime()

for doctype in frappe.get_list("DocType", filters={"istable": 0, "issingle": 0}):
for dt_tags in frappe.db.sql("select `name`, `_user_tags` from `tab{0}`".format(doctype.name), as_dict=True):
tags = dt_tags.get("_user_tags").split(",") if dt_tags.get("_user_tags") else None
if not tags:
continue

for tag in tags:
if not tag:
continue

escaped_tag = frappe.db.escape(tag.strip())
tag_list.append((escaped_tag, time, time, 'Administrator'))

tag_link_name = frappe.generate_hash(dt_tags.name + escaped_tag, 10),
tag_links.append((tag_link_name, doctype.name, dt_tags.name, escaped_tag, time, time, 'Administrator'))

frappe.db.bulk_insert("Tag", fields=["name", "creation", "modified", "modified_by"], values=tag_list)
frappe.db.bulk_insert("Tag Link", fields=["name", "document_type", "document_name", "tag", "creation", "modified", "modified_by"], values=tag_links)

+ 1
- 0
frappe/public/build.json Просмотреть файл

@@ -186,6 +186,7 @@
"public/js/frappe/ui/toolbar/awesome_bar.js",
"public/js/frappe/ui/toolbar/energy_points_notifications.js",
"public/js/frappe/ui/toolbar/search.js",
"public/js/frappe/ui/toolbar/tag_utils.js",
"public/js/frappe/ui/toolbar/search.html",
"public/js/frappe/ui/toolbar/search_header.html",
"public/js/frappe/ui/toolbar/search_utils.js",


+ 6
- 0
frappe/public/js/frappe/desk.js Просмотреть файл

@@ -147,6 +147,8 @@ frappe.Application = Class.extend({
});
}, 300000); // check every 5 minutes
}

this.fetch_tags();
},

setup_frappe_vue() {
@@ -599,6 +601,10 @@ frappe.Application = Class.extend({
frappe.show_alert(message);
});
},

fetch_tags() {
frappe.tags.utils.fetch_tags();
}
});

frappe.get_module = function(m, default_module) {


+ 2
- 3
frappe/public/js/frappe/form/sidebar/form_sidebar.js Просмотреть файл

@@ -76,7 +76,7 @@ frappe.ui.form.Sidebar = Class.extend({
this.frm.shared.refresh();
this.frm.follow.refresh();
this.frm.viewers.refresh();
this.frm.tags && this.frm.tags.refresh(this.frm.doc._user_tags);
this.frm.tags && this.frm.tags.refresh(this.frm.get_docinfo().tags);
this.sidebar.find(".modified-by").html(__("{0} edited this {1}",
["<strong>" + frappe.user.full_name(this.frm.doc.modified_by) + "</strong>",
"<br>" + comment_when(this.frm.doc.modified)]));
@@ -119,7 +119,6 @@ frappe.ui.form.Sidebar = Class.extend({
},

make_tags: function() {
var me = this;
if (this.frm.meta.issingle) {
this.sidebar.find(".form-tags").toggle(false);
return;
@@ -129,7 +128,7 @@ frappe.ui.form.Sidebar = Class.extend({
parent: this.sidebar.find(".tag-area"),
frm: this.frm,
on_change: function(user_tags) {
me.frm.doc._user_tags = user_tags;
this.frm.tags && this.frm.tags.refresh(user_tags);
}
});
},


+ 5
- 26
frappe/public/js/frappe/list/list_sidebar.js Просмотреть файл

@@ -288,29 +288,7 @@ frappe.views.ListSidebar = class ListSidebar {
filters: me.default_filters || []
},
callback: function(r) {
me.defined_category = r.message;
if (r.message.defined_cat) {
me.defined_category = r.message.defined_cat;
me.cats = {};
//structure the tag categories
for (var i in me.defined_category) {
if (me.cats[me.defined_category[i].category] === undefined) {
me.cats[me.defined_category[i].category] = [me.defined_category[i].tag];
} else {
me.cats[me.defined_category[i].category].push(me.defined_category[i].tag);
}
me.cat_tags[i] = me.defined_category[i].tag;
}
me.tempstats = r.message.stats;

$.each(me.cats, function(i, v) {
me.render_stat(i, (me.tempstats || {})["_user_tags"], v);
});
me.render_stat("_user_tags", (me.tempstats || {})["_user_tags"]);
} else {
//render normal stats
me.render_stat("_user_tags", (r.message.stats || {})["_user_tags"]);
}
me.render_stat("_user_tags", (r.message.stats || {})["_user_tags"]);
let stats_dropdown = me.sidebar.find('.list-stats-dropdown');
me.setup_dropdown_search(stats_dropdown,'.stat-label');
}
@@ -355,13 +333,14 @@ frappe.views.ListSidebar = class ListSidebar {
field: field,
stat: stats,
sum: sum,
label: field === '_user_tags' ? (tags ? __(label) : __("Tags")) : __(label),
label: field === '_user_tags' ? (tags ? __(label) : __("Tag")) : __(label),
};
$(frappe.render_template("list_sidebar_stat", context))
.on("click", ".stat-link", function() {
var doctype = "Tag Link";
var fieldname = $(this).attr('data-field');
var label = $(this).attr('data-label');
var condition = "like";
var condition = "=";
var existing = me.list_view.filter_area.filter_list.get_filter(fieldname);
if(existing) {
existing.remove();
@@ -370,7 +349,7 @@ frappe.views.ListSidebar = class ListSidebar {
label = "%,%";
condition = "not like";
}
me.list_view.filter_area.filter_list.add_filter(me.list_view.doctype, fieldname, condition, label)
me.list_view.filter_area.filter_list.add_filter(doctype, fieldname, condition, label)
.then(function() {
me.list_view.refresh();
});


+ 1
- 1
frappe/public/js/frappe/list/list_view.js Просмотреть файл

@@ -432,7 +432,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {

tag_editor.wrapper.on('click', '.tagit-label', (e) => {
const $this = $(e.currentTarget);
this.filter_area.add(this.doctype, '_user_tags', '=', $this.text());
this.filter_area.add('Tag Link', 'tag', '=', $this.text());
});
});
}


+ 7
- 0
frappe/public/js/frappe/ui/filters/filter.js Просмотреть файл

@@ -173,6 +173,13 @@ frappe.ui.Filter = class {
if(this.field) for(let k in this.field.df) cur[k] = this.field.df[k];

let original_docfield = (this.fieldselect.fields_by_name[doctype] || {})[fieldname];

if (doctype === "Tag Link" || fieldname === "_user_tags") {
original_docfield = {fieldname: "tag", fieldtype: "Data", label: "Tags", parent: "Tag Link"};
doctype = "Tag Link";
condition = "=";
}

if(!original_docfield) {
console.warn(`Field ${fieldname} is not selectable.`);
this.remove();


+ 5
- 5
frappe/public/js/frappe/ui/tag_editor.js Просмотреть файл

@@ -35,13 +35,14 @@ frappe.ui.TagEditor = Class.extend({
onTagAdd: (tag) => {
if(me.initialized && !me.refreshing) {
return frappe.call({
method: 'frappe.desk.tags.add_tag',
method: "frappe.desk.doctype.tag.tag.add_tag",
args: me.get_args(tag),
callback: function(r) {
var user_tags = me.user_tags ? me.user_tags.split(",") : [];
user_tags.push(tag)
me.user_tags = user_tags.join(",");
me.on_change && me.on_change(me.user_tags);
frappe.tags.utils.fetch_tags();
}
});
}
@@ -49,13 +50,14 @@ frappe.ui.TagEditor = Class.extend({
onTagRemove: (tag) => {
if(!me.refreshing) {
return frappe.call({
method: 'frappe.desk.tags.remove_tag',
method: "frappe.desk.doctype.tag.tag.remove_tag",
args: me.get_args(tag),
callback: function(r) {
var user_tags = me.user_tags.split(",");
user_tags.splice(user_tags.indexOf(tag), 1);
me.user_tags = user_tags.join(",");
me.on_change && me.on_change(me.user_tags);
frappe.tags.utils.fetch_tags();
}
});
}
@@ -82,12 +84,10 @@ frappe.ui.TagEditor = Class.extend({
$input.on("input", function(e) {
var value = e.target.value;
frappe.call({
method:"frappe.desk.tags.get_tags",
method: "frappe.desk.doctype.tag.tag.get_tags",
args:{
doctype: me.frm.doctype,
txt: value.toLowerCase(),
cat_tags: me.list_sidebar ?
JSON.stringify(me.list_sidebar.get_cat_tags()) : '[]'
},
callback: function(r) {
me.awesomplete.list = r.message;


+ 0
- 1
frappe/public/js/frappe/ui/tags.js Просмотреть файл

@@ -67,7 +67,6 @@ frappe.ui.Tags = class {
}

addTag(label) {
label = toTitle(label);
if(label && label!== '' && !this.tagsList.includes(label)) {
let $tag = this.getTag(label);
this.getListElement($tag).insertBefore(this.$inputWrapper);


+ 11
- 0
frappe/public/js/frappe/ui/toolbar/awesome_bar.js Просмотреть файл

@@ -1,6 +1,7 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// MIT License. See license.txt
frappe.provide('frappe.search');
frappe.provide('frappe.tags');

frappe.search.AwesomeBar = Class.extend({
setup: function(element) {
@@ -140,6 +141,8 @@ frappe.search.AwesomeBar = Class.extend({
__("document type..., e.g. customer")+'</td></tr>\
<tr><td>'+__("Search in a document type")+'</td><td>'+
__("text in document type")+'</td></tr>\
<tr><td>'+__("Tags")+'</td><td>'+
__("tag name..., e.g. #tag")+'</td></tr>\
<tr><td>'+__("Open a module or tool")+'</td><td>'+
__("module name...")+'</td></tr>\
<tr><td>'+__("Calculate")+'</td><td>'+
@@ -177,6 +180,9 @@ frappe.search.AwesomeBar = Class.extend({
frappe.search.utils.get_recent_pages(txt || ""),
frappe.search.utils.get_executables(txt)
);
if (txt.charAt(0) === "#") {
options = frappe.tags.utils.get_tags(txt);
}
var out = this.deduplicate(options);
return out.sort(function(a, b) {
return b.index - a.index;
@@ -215,6 +221,11 @@ frappe.search.AwesomeBar = Class.extend({

make_global_search: function(txt) {
var me = this;

if (txt.charAt(0) === "#") {
return;
}

this.options.push({
label: __("Search for '{0}'", [txt.bold()]),
value: __("Search for '{0}'", [txt]),


+ 47
- 3
frappe/public/js/frappe/ui/toolbar/search.js Просмотреть файл

@@ -124,6 +124,11 @@ frappe.search.SearchDialog = Class.extend({
// Help results
// this.$modal_body.on('click', 'a[data-path]', frappe.help.show_results);
this.bind_keyboard_events();

// Setup Minimizable functionality
this.search_dialog.minimizable = true;
this.search_dialog.is_minimized = false;
this.search_dialog.$wrapper.find('.btn-modal-minimize').click(() => this.toggle_minimize());
},

bind_keyboard_events: function() {
@@ -178,7 +183,17 @@ frappe.search.SearchDialog = Class.extend({
} else {
this.$search_modal.find('.loading-state').removeClass('hide');
}

if (this.current_keyword.charAt(0) === "#") {
this.search = this.searches["tags"];
} else {
this.search = this.searches["global_search"];
}

this.search.get_results(keywords, this.parse_results.bind(this));
if (this.search_dialog.is_minimized) {
this.toggle_minimize();
}
},

parse_results: function(result_sets, keyword) {
@@ -308,7 +323,7 @@ frappe.search.SearchDialog = Class.extend({
frappe.route_options = result.route_options;
}
$result.on('click', (e) => {
this.search_dialog.hide();
this.toggle_minimize();
if(result.onclick) {
result.onclick(result.match);
} else {
@@ -353,12 +368,25 @@ frappe.search.SearchDialog = Class.extend({
this.$modal_body.find('.more-results.last').slideDown(200, function() {});
},

get_minimize_btn: function() {
return this.search_dialog.$wrapper.find(".modal-header .btn-modal-minimize");
},

toggle_minimize: function() {
let modal = this.search_dialog.$wrapper.closest('.modal').toggleClass('modal-minimize');
modal.attr('tabindex') ? modal.removeAttr('tabindex') : modal.attr('tabindex', -1);
this.get_minimize_btn().find('i').toggleClass('octicon-chevron-down').toggleClass('octicon-chevron-up');
this.search_dialog.is_minimized = !this.search_dialog.is_minimized;
this.on_minimize_toggle && this.on_minimize_toggle(this.search_dialog.is_minimized);
this.search_dialog.header.find('.modal-title').toggleClass('cursor-pointer');
},

// Search objects
searches: {
global_search: {
input_placeholder: __("Global Search"),
input_placeholder: __("Search"),
empty_state_text: __("Search for anything"),
no_results_status: (keyword) => __("<p>No results found for '" + keyword + "' in Global Search</p>"),
no_results_status: (keyword) => "<p>" + __("No results found for {0} in Global Search", [keyword]) + "</p>",

get_results: function(keywords, callback) {
var start = 0, limit = 1000;
@@ -372,6 +400,22 @@ frappe.search.SearchDialog = Class.extend({
});
}
},
tags: {
input_placeholder: __("Search"),
empty_state_text: __("Search for anything"),
no_results_status: (keyword) => "<p>" + __("No documents found tagged with {0}", [keyword]) + "</p>",

get_results: function(keywords, callback) {
var results = frappe.search.utils.get_nav_results(keywords);
frappe.tags.utils.get_tag_results(keywords)
.then(function(global_results) {
results = results.concat(global_results);
callback(results, keywords);
}, function (err) {
console.error(err);
});
}
},
},

});

+ 10
- 4
frappe/public/js/frappe/ui/toolbar/search_header.html Просмотреть файл

@@ -1,6 +1,12 @@
<div class="search-header">
<i class="octicon octicon-search"></i>
<input type="text" class="form-control search-input" style="padding-left: 15px">
<p class="loading-state hide" style="margin: 0px 20px; color:#d4d9dd">{%= __("Searching")%}&nbsp...</p>
<a type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</a>
<i class="octicon octicon-search"></i>
<input type="text" class="form-control search-input" style="padding-left: 15px">
<p class="loading-state hide" style="margin: 0px 20px; color:#d4d9dd">{%= __("Searching")%}&nbsp...</p>
<a type="button" class="btn btn-default btn-sm btn-modal-minimize" style="margin-right: 2px;">
<i class="octicon octicon-chevron-down" style="padding: 1px 0px;"></i>
</a>
<a type="button" class="btn btn-default btn-sm btn-modal-close" data-dismiss="modal" aria-hidden="true">
<i class="octicon octicon-x visible-xs" style="padding: 1px 0px;"></i>
<span class="hidden-xs">Close</span>
</a>
</div>

+ 108
- 0
frappe/public/js/frappe/ui/toolbar/tag_utils.js Просмотреть файл

@@ -0,0 +1,108 @@
// Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
// MIT License. See license.txt

frappe.provide("frappe.tags");

frappe.tags.utils = {
get_tags: function(txt) {
txt = txt.slice(1);
let out = [];

for (let i in frappe.tags.tags) {
let tag = frappe.tags.tags[i];
let level = frappe.search.utils.fuzzy_search(txt, tag);
if (level) {
out.push({
type: "Tag",
label: __("#{0}", [frappe.search.utils.bolden_match_part(__(tag), txt)]),
value: __("#{0}", [__(tag)]),
index: 1 + level,
match: tag,
onclick() {
// Use Global Search Dialog for tag search too.
frappe.searchdialog.search.init_search("#".concat(tag), "tags");
}
});
}
}

return out;
},

fetch_tags() {
frappe.call({
method: "frappe.desk.doctype.tag.tag.get_tags_list_for_awesomebar",
callback: function(r) {
if (r && r.message) {
frappe.tags.tags = $.extend([], r.message);
}
}
});
},

get_tag_results: function(tag) {
function get_results_sets(data) {
var results_sets = [], result, set;
function get_existing_set(doctype) {
return results_sets.find(function(set) {
return set.title === doctype;
});
}

function make_description(content) {
var field_length = 110;
var field_value = null;
if (content.length > field_length) {
field_value = content.slice(0, field_length) + "...";
} else {
var length = content.length;
field_value = content.slice(0, length) + "...";
}
return field_value;
}

data.forEach(function(d) {
// more properties
var description = "";
if (d.content) {
description = make_description(d.content);
}
result = {
label: d.name,
value: d.name,
description: description,
route: ['Form', d.doctype, d.name],

};
set = get_existing_set(d.doctype);
if (set) {
set.results.push(result);
} else {
set = {
title: d.doctype,
results: [result],
fetch_type: "Global"
};
results_sets.push(set);
}

});
return results_sets;
}
return new Promise(function(resolve) {
frappe.call({
method: "frappe.desk.doctype.tag.tag.get_documents_for_tag",
args: {
tag: tag
},
callback: function(r) {
if (r.message) {
resolve(get_results_sets(r.message));
} else {
resolve([]);
}
}
});
});
},
};

+ 0
- 4
frappe/utils/global_search.py Просмотреть файл

@@ -242,10 +242,6 @@ def update_global_search(doc):
if doc.get(field.fieldname) and field.fieldtype not in frappe.model.table_fields:
content.append(get_formatted_value(doc.get(field.fieldname), field))

tags = (doc.get('_user_tags') or '').strip()
if tags:
content.extend(list(filter(lambda x: x, tags.split(','))))

# Get children
for child in doc.meta.get_table_fields():
for d in doc.get(child.fieldname):


Загрузка…
Отмена
Сохранить