1. Home
  2. Docs
  3. Infrastructure
  4. ERPNext
  5. ERPNext Custom DocTypes, Actions, and Links

ERPNext Custom DocTypes, Actions, and Links

DocType Actions can be used to implement functionality as a button in that DocType’s view.

DocType Links add hyperlinks to related documents (stored as Link fields) in that DocType’s view.

Frappe Custom DocTypes

Field types tips & warnings:

Name & Title fields: When using it in a Doctype or Child Table, with Link or Table-MultiSelect, it will show built-in Name (or ID) field by default. If you want to use/display Title instead, there are two choices:

  1. Set Auto Name to “field:title. This will set the actual primary key ID same as Title. Note that existing documents will need to be renamed, you may want to enable “Allow Rename” as well.
  2. Create another Data field in referring Doctype. Set it as read-only. Then set its Fetch From, assuming the Link field is named job_role, then you set Fetch From to job_role.title. This way you’ll have two fields (Link field + read-only Title field of that Link) instead of just one Link field.

Table-MultiSelect field: As of ERPNext v13.beta.4, Table-MultiSelect fields are not filterable (originally authored by Faris Ansari). On Sep 23, 2020, Hendy offered to sponsor Faris to work on this filter.

Custom Actions

Tutorial: 3 custom action UIs: custom buttons, custom menus, custom icon buttons

Discussion: https://discuss.erpnext.com/t/erpnext-v12-3-1-new-doctype-doctype-action-doctype-link-child-table/56659?u=hendy

That will give you a doctype edit form UI like this:

The action should be named according to path, like app_name.module_name.doctype.doctype_name.doctype_name.verb, e.g. lovia.talentiva.doctype.student_account_request.student_account_request.create_sso_account. Then implemented as Frappe Remote Method Call.

That method must be implemented as Python function. For the example above, it will be in file frappe-bench/apps/lovia/lovia/talentiva/doctype/student_account_request/student_account_request.py and function name create_sso_account. It must be decorated with @frappe.whitelist(). The function needs to accept a str parameter (JSON-encoded) called doc. Tip: If you’re not sure about what parameters and what types, you can inspect the REST API request using web browser’s Console.

# -*- coding: utf-8 -*-
# Copyright (c) 2020, Lovia and contributors
# For license information, please see license.txt

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

class StudentAccountRequest(Document):
	pass

@frappe.whitelist()
def create_sso_account(doc: str):
	doc_dict = json.loads(doc)
	print('doc=%s' % (doc_dict,))
	student_account_request = frappe.get_doc('Student Account Request', doc_dict['name'])
	print('student_account_request=%s' % (student_account_request,))
	return {'message': 'Hello!!!!', 'status': 'oh yes'}

Important: When changing Python code, you’ll need to restart web app (bench/frappe serve), otherwise it won’t take effect.

When action is clicked, it will send a POST HTTP request to http://localhost:8000/api/method/lovia.talentiva.doctype.student_account_request.student_account_request.create_sso_account:

Simple String response will be returned to client/browser as JSON inside “message” field. After you get the simple response working, a better way to inform user is to use frappe.msgprint() (see Frappe Python API below).

Example complete student_account_request.py implementation with Frappe Python API and REST API client request:

from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
import json
from uuid import uuid4
from datetime import datetime
import requests

@frappe.whitelist()
def create_sso_account(doc: str):
	doc_dict = json.loads(doc)
	print('doc=%s' % (doc_dict,))
	student_account_request = frappe.get_doc('Student Account Request', doc_dict['name'])
	print('student_account_request=%s' % (student_account_request,))

	sso_id = uuid4()
	student_account_request.sso_id = str(sso_id)
	student_account_request.sso_username = doc_dict['desired_username']

	fusionauth_api_key = FUSIONAUTH_API_KEY
	print('Creating User & Lovia User Registration for "%s" with ID "%s"...' % (student_account_request.sso_username, student_account_request.sso_id))
	lovia_user_resp = requests.post('https://login.lovia.life/api/user/registration/%s' % (student_account_request.sso_id,),
		headers={'Authorization': fusionauth_api_key},
		json={
			'user': {
        "birthDate": student_account_request.birthdate.isoformat(),
        "email": student_account_request.email_personal,
        "fullName": student_account_request.full_name,
        "imageUrl": 'https://erp.lovia.life' + student_account_request.profile_photo,
        "mobilePhone": student_account_request.mobile_number,
        "passwordChangeRequired": True,
        "preferredLanguages": ["id", "en"],
        "timezone": "Asia/Jakarta",
        "username": student_account_request.sso_username,
				'data': {
					'email_school': student_account_request.email_school,
					'gender': student_account_request.gender,
					'school': student_account_request.school,
					'student_number': student_account_request.student_number,
					'classroom': student_account_request.classroom,
				}
			},
			'sendSetPasswordEmail': True,
			'skipVerification': False,
			'registration': {
        "applicationId": "5691331c-b95d-4214-9de0-7ef0fd067cf9",
        "roles": ["user"]
			}
		})
	print('Lovia User: %s %s' % (lovia_user_resp.status_code, lovia_user_resp.text,))
	if lovia_user_resp.status_code != 200:
		frappe.throw(lovia_user_resp.text, exc=RuntimeError, title='Cannot create Lovia user & registration')
	print('Registering Talentiva for "%s" with ID "%s"...' % (student_account_request.sso_username, student_account_request.sso_id))
	talentiva_reg_resp = requests.post('https://login.lovia.life/api/user/registration/%s' % (student_account_request.sso_id,),
		headers={'Authorization': fusionauth_api_key},
		json={
			'registration': {
        "applicationId": "caf92ff9-cadd-49dc-9255-3e716c1775ab",
        "roles": ["user"]
			}
		})
	print('Talentiva Registration: %s %s' % (talentiva_reg_resp.status_code, talentiva_reg_resp.text,))
	if talentiva_reg_resp.status_code != 200:
		frappe.throw(talentiva_reg_resp.text, exc=RuntimeError, title='Cannot create Talentiva registration')

	student_account_request.sso_created_at = datetime.now()
	student_account_request.save()

	frappe.msgprint(
		msg='User "%s" created as "%s" and registered to Lovia & Talentiva.' % (student_account_request.sso_username, student_account_request.sso_id),
		title='Lovia User & Registration',
		indicator='green')

Frappe/ERPNext Python API

References:

To throw exception/error, use frappe.throw().

To display modal message, use frappe.msgprint().

For Document hooks and CRUD events, you can put them inside apps/APP_NAME/APP_NAME/MODULE_NAME/doctype/DOCTYPE_NAME/DOCTYPE_NAME.py. As alternative, check out also APP_NAME/hooks.py. Sample before_save hook callback below:

from __future__ import unicode_literals
import frappe
from frappe.model.document import Document
from frappe.utils import getdate
from datetime import datetime
import dateutil

class Talent(Document):
	pass

	def before_save(self):
		# Calculate age
		if self.birthdate:
			born = getdate(self.birthdate)
			age = dateutil.relativedelta.relativedelta(getdate(), born)
			self.age = age.years
			frappe.logger().info('Talent %s was born %s, calculated age=%d' % (self.person_name, born, self.age))
		elif self.estimated_birth_year:
			self.age = datetime.today().year - self.estimated_birth_year
			frappe.logger().info('Talent %s was born estimated year %s, estimated age=%d' % (self.person_name, self.estimated_birth_year, self.age))
		# Set last_known_monthly_salary if talent filled previous_monthly_salary
		if self.last_known_monthly_salary is None and self.previous_monthly_salary is not None:
			self.last_known_monthly_salary = self.previous_monthly_salary
		# Set last_known_employment_date if we have last employment information
		if self.last_known_employment_date is None:
			if self.last_known_employment or self.last_known_employer or self.last_known_job_role or \
					(self.last_known_monthly_salary is not None and self.last_known_monthly_salary > 0):
				self.last_known_employment_date = datetime.now()

Calculated Field using Custom Doctype Script

Calculate Employee Age from Birthdate

Reference:

Custom (Client) Script:

frappe.ui.form.on('Talent', {
	refresh: function(frm) {
	    if (frm.doc.birthdate) {
		    frm.set_value('age', moment().diff(frm.doc.birthdate, 'years'));
	    } else {
	        frm.set_value('age', null);
	    }
	    frm.refresh_field('age');
	},
    birthdate: function(frm, cdt, cdn) {
        // https://docs.erpnext.com/docs/user/manual/en/customize-erpnext/custom-scripts/update-date-field-based-on-value-in-other-date-field
        // https://discuss.erpnext.com/t/calculate-age-for-a-student/20235/16?u=hendy
        if (frm.doc.birthdate) {
            frm.set_value('age', moment().diff(frm.doc.birthdate, 'years'));
        } else {
            frm.set_value('age', null);
        }
        frm.refresh_field('age');
    }
});

To add Custom Client Script, there are two ways:

  1. Recommended is to change file in app’s module, e.g. frappe-bench/apps/lovia/lovia/talentiva/doctype/talent/talent.js.
  2. For ad hoc changes, you can use ERPNext > Custom Script.

If you want to use server-side calculation instead, see https://discuss.erpnext.com/t/how-to-calc-and-update-a-field-before-save/44101/7?u=hendy

def validade(self):
   self.calc_estoque()

def calc_estoque(doc, method):
    doc.est_disp = (doc.est or 0) - (doc.est_res or 0)

Form Scripting

Reference: https://medium.com/@basawarajsavalagi/form-scripting-in-frappe-framework-996c949c6b76

Custom Fields: Customize Form vs Custom App

Reference: Customize Form vs Editing Doctype. TL;DR: If we want to add/edit fields from built-in doctypes that are specific to a site, we use Customize form. Otherwise, we should use Custom Add, we can add a new doctype, and also add new fields to built-in doctypes.

When we use Customize form or use the custom folder in custom app, it is the same as adding Custom Field documents. So when you use custom folder, you cannot delete already added fields using the custom app’s JSON definition, you’ll need to use “Custom Field List” UI to delete them.

The third way is custom folder. This is used by erpnext/erpnext/erpnext_integrations/custom/contact.json. and erpnext/erpnext/erpnext_integrations/doctype/shopify_settings/test_data/custom_field.json. It seems to be undocumented by Hendy thinks it’s cleaner. Only two files of all ERPNext uses this technique.

To add custom fields to built-in doctypes using custom app, add a file e.g. frappe-bench/apps/lovia/lovia/lovia/custom/contact.json:

{
  "custom_fields": [
    {
      "creation": "2020-10-19 11:00:05.000000",
      "modified": "2020-10-19 11:00:06.000000",
      "dt": "Contact",
      "insert_after": "phone_nos",
      "fieldname": "lovia_integration_section",
      "fieldtype": "Section Break",
      "label": "Lovia Integration"
    },
    {
      "creation": "2020-10-19 11:00:03.432994",
      "modified": "2020-10-19 11:00:05.000000",
      "dt": "Contact",
      "insert_after": "lovia_integration_section",
      "description": "FusionAuth SSO ID",
      "fieldname": "sso_id",
      "fieldtype": "Data",
      "in_filter": 1,
      "in_standard_filter": 1,
      "label": "SSO ID",
      "search_index": 1,
      "in_global_search": 1
    },
    {
      "creation": "2020-10-19 11:00:03.432994",
      "modified": "2020-10-19 11:00:05.000000",
      "dt": "Contact",
      "insert_after": "sso_id",
      "description": "Username in Lovia",
      "fieldname": "lovia_username",
      "fieldtype": "Data",
      "in_filter": 1,
      "in_standard_filter": 1,
      "label": "Lovia Username",
      "search_index": 1,
      "in_global_search": 1
    },
    {
      "creation": "2020-10-19 11:00:03.432994",
      "modified": "2020-10-19 11:00:05.000000",
      "dt": "Contact",
      "insert_after": "lovia_username",
      "fieldname": "lovia_integration_break",
      "fieldtype": "Column Break"
    },
    {
      "creation": "2020-10-19 11:00:03.432994",
      "modified": "2020-10-19 11:00:06.000000",
      "dt": "Contact",
      "insert_after": "lovia_integration_break",
      "description": "User ID used by Miluv v4 (integer)",
      "fieldname": "miluv_v4_id",
      "fieldtype": "Int",
      "in_filter": 1,
      "in_standard_filter": 1,
      "label": "Miluv v4 ID",
      "search_index": 1,
      "in_global_search": 1
    }
  ],
  "custom_perms": [],
  "doctype": "Contact",
  "property_setters": [],
  "sync_on_migrate": 1
}

After this you’ll need to do bench migrate. (or if you need to pull the repository first, bench --pull --patch --apps lovia)

Result:

How can we help?