Overview
DocTypes are the core building blocks of Frappe applications. They define the structure of your data, business logic, permissions, and UI behavior. All Helpdesk DocTypes are located inhelpdesk/helpdesk/doctype/.
DocType Structure
Each DocType consists of:{doctype_name}.json- Field definitions and metadata{doctype_name}.py- Python controller class with business logic{doctype_name}.js- Client-side JavaScript (optional)test_{doctype_name}.py- Unit tests
Example: HD Ticket DocType
JSON Definition (hd_ticket.json)
{
"actions": [],
"allow_import": 1,
"autoname": "autoincrement",
"creation": "2023-10-17 14:27:11.078679",
"doctype": "DocType",
"document_type": "Setup",
"email_append_to": 1,
"field_order": [
"subject_section",
"subject",
"raised_by",
"status",
"priority",
"ticket_type",
"description"
],
"fields": [
{
"fieldname": "subject",
"fieldtype": "Data",
"in_global_search": 1,
"in_standard_filter": 1,
"label": "Subject",
"reqd": 1
},
{
"fieldname": "raised_by",
"fieldtype": "Data",
"label": "Raised By (Email)",
"options": "Email"
},
{
"fieldname": "status",
"fieldtype": "Select",
"label": "Status"
}
]
}
Python Controller (hd_ticket.py)
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import now_datetime
from helpdesk.utils import publish_event, get_doc_room
class HDTicket(Document):
@property
def default_open_status(self):
"""Get default open status from settings"""
return frappe.db.get_single_value("HD Settings", "default_ticket_status")
def autoname(self):
"""Custom naming logic"""
return self.name
def before_insert(self):
"""Called before document is inserted into database"""
self.generate_key()
def before_validate(self):
"""Called before validation"""
self.check_update_perms()
self.set_ticket_type()
self.set_raised_by()
self.set_priority()
self.set_default_status()
def validate(self):
"""Called during validation"""
self.validate_feedback()
def before_save(self):
"""Called before document is saved"""
if not self.is_new():
self.handle_ticket_activity_update()
def after_insert(self):
"""Called after document is inserted"""
self.send_acknowledgement_email()
def on_update(self):
"""Called after document is updated"""
self.publish_update()
def publish_update(self):
"""Publish real-time update to connected clients"""
room = get_doc_room("HD Ticket", self.name)
publish_event(
"helpdesk:ticket-update",
room=room,
data={"ticket_id": self.name}
)
def assign_agent(self, agent_id: str):
"""Custom method to assign ticket to agent"""
if not frappe.db.exists("HD Agent", agent_id):
frappe.throw(_("Invalid agent ID"))
# Update assignment
from frappe.desk.form.assign_to import add as assign
assign({
"doctype": self.doctype,
"name": self.name,
"assign_to": [agent_id],
})
# Log activity
self.add_comment(
"Info",
_("Ticket assigned to {0}").format(agent_id)
)
# Permission functions (module-level)
def has_permission(doc, ptype, user):
"""Custom permission logic"""
from helpdesk.utils import is_agent, is_admin
# Admins and agents have full access
if is_admin(user) or is_agent(user):
return True
# Customers can only view their own tickets
if ptype == "read" and doc.raised_by == user:
return True
return False
def permission_query(user):
"""Filter query based on user permissions"""
from helpdesk.utils import is_agent, is_admin
if is_admin(user) or is_agent(user):
return None # No restrictions
# Customers see only their tickets
return f"(`tabHD Ticket`.raised_by = {frappe.db.escape(user)})"
DocType Lifecycle Hooks
Before Hooks
class MyDocType(Document):
def before_insert(self):
"""Before document is inserted"""
self.validate_unique_field()
def before_validate(self):
"""Before validation starts"""
self.set_defaults()
def before_save(self):
"""Before document is saved (insert or update)"""
self.calculate_totals()
def before_submit(self):
"""Before submittable document is submitted"""
self.validate_submission()
def before_cancel(self):
"""Before submittable document is cancelled"""
self.check_cancel_permission()
Validation Hooks
class MyDocType(Document):
def validate(self):
"""Main validation method"""
self.validate_dates()
self.validate_amounts()
self.validate_dependencies()
def validate_dates(self):
from frappe.utils import getdate
if getdate(self.end_date) < getdate(self.start_date):
frappe.throw(_("End date cannot be before start date"))
After Hooks
class MyDocType(Document):
def after_insert(self):
"""After document is inserted"""
self.send_notification()
self.create_related_documents()
def on_update(self):
"""After document is updated"""
self.update_related_documents()
def on_submit(self):
"""After submittable document is submitted"""
self.update_stock_ledger()
def on_cancel(self):
"""After submittable document is cancelled"""
self.reverse_stock_ledger()
def on_trash(self):
"""Before document is deleted"""
self.delete_related_documents()
Custom Permissions
Document-Level Permissions
# In your doctype.py file
def has_permission(doc, ptype, user):
"""
Custom permission check for individual documents
:param doc: Document instance
:param ptype: Permission type (read, write, delete, submit, etc.)
:param user: User to check permission for
:return: True if user has permission, False otherwise
"""
from helpdesk.utils import is_agent, get_agents_team
if ptype == "read":
# Anyone can read
return True
if ptype == "write":
# Only assigned agent or their team can write
if is_agent(user):
assigned_agents = frappe.get_all(
"ToDo",
filters={
"reference_type": doc.doctype,
"reference_name": doc.name,
"status": "Open",
},
pluck="allocated_to"
)
return user in assigned_agents
return False
def permission_query(user):
"""
Custom permission query to filter list views
:param user: User to check permission for
:return: SQL condition string or None
"""
from helpdesk.utils import is_agent, is_admin
if is_admin(user) or is_agent(user):
return None # No restrictions
# Regular users see only their own documents
return f"(`tabHD Ticket`.raised_by = {frappe.db.escape(user)})"
Register Permissions in hooks.py
# In helpdesk/hooks.py
has_permission = {
"HD Ticket": "helpdesk.helpdesk.doctype.hd_ticket.hd_ticket.has_permission",
"HD Saved Reply": "helpdesk.helpdesk.doctype.hd_saved_reply.hd_saved_reply.has_permission",
}
permission_query_conditions = {
"HD Ticket": "helpdesk.helpdesk.doctype.hd_ticket.hd_ticket.permission_query",
"HD Saved Reply": "helpdesk.helpdesk.doctype.hd_saved_reply.hd_saved_reply.permission_query",
}
Database Queries in DocTypes
Using Query Builder
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count
from pypika import Order
class HDTicket(Document):
def get_related_tickets(self):
"""Get tickets from same customer"""
Ticket = DocType("HD Ticket")
query = (
frappe.qb.from_(Ticket)
.select(
Ticket.name,
Ticket.subject,
Ticket.status,
Ticket.creation
)
.where(Ticket.raised_by == self.raised_by)
.where(Ticket.name != self.name)
.orderby(Ticket.creation, order=Order.desc)
.limit(10)
)
return query.run(as_dict=True)
def get_ticket_count_by_status(self):
"""Get ticket count grouped by status"""
Ticket = DocType("HD Ticket")
query = (
frappe.qb.from_(Ticket)
.select(
Ticket.status,
Count(Ticket.name).as_("count")
)
.where(Ticket.raised_by == self.raised_by)
.groupby(Ticket.status)
)
return query.run(as_dict=True)
Simple Database Operations
class HDTicket(Document):
def get_assigned_agent(self):
"""Get currently assigned agent"""
return frappe.db.get_value(
"ToDo",
{
"reference_type": self.doctype,
"reference_name": self.name,
"status": "Open",
},
"allocated_to"
)
def count_open_tickets_for_customer(self):
"""Count open tickets for this customer"""
return frappe.db.count(
"HD Ticket",
{
"raised_by": self.raised_by,
"status": ["in", ["Open", "Replied"]],
}
)
Child Tables
# Parent DocType
class HDTicket(Document):
def add_custom_field_value(self, field_name: str, value: str):
"""Add value to child table"""
self.append("custom_fields", {
"field_name": field_name,
"value": value,
})
self.save()
def get_custom_field_value(self, field_name: str):
"""Get value from child table"""
for row in self.custom_fields:
if row.field_name == field_name:
return row.value
return None
Creating DocTypes Programmatically
Use the bench command:bench --site helpdesk.test new-doctype "My Custom DocType"
import frappe
def create_custom_doctype():
"""Create a custom DocType programmatically"""
if not frappe.db.exists("DocType", "My Custom DocType"):
doc = frappe.get_doc({
"doctype": "DocType",
"name": "My Custom DocType",
"module": "Helpdesk",
"custom": 1,
"fields": [
{
"fieldname": "title",
"fieldtype": "Data",
"label": "Title",
"reqd": 1,
},
{
"fieldname": "description",
"fieldtype": "Text Editor",
"label": "Description",
},
],
"permissions": [
{
"role": "Agent",
"read": 1,
"write": 1,
"create": 1,
"delete": 1,
},
],
})
doc.insert()
Virtual Fields
class HDTicket(Document):
@property
def days_since_creation(self):
"""Virtual field: days since ticket was created"""
from frappe.utils import date_diff, getdate, nowdate
return date_diff(nowdate(), getdate(self.creation))
@property
def is_overdue(self):
"""Virtual field: check if ticket is overdue"""
from frappe.utils import now_datetime
if self.resolution_by:
return now_datetime() > self.resolution_by
return False
Best Practices
- Validation: Always validate data in
validate()method - Permissions: Implement custom permissions for sensitive data
- Query Builder: Use Query Builder instead of raw SQL
- Error Handling: Use
frappe.throw()for user-facing errors - Logging: Use
frappe.log_error()for debugging - Real-time: Publish events for UI updates
- Child Tables: Use child tables for one-to-many relationships
- Virtual Fields: Use
@propertyfor computed fields - Transactions: Batch operations and commit manually if needed
- Testing: Write unit tests for all business logic
Testing DocTypes
Create tests intest_{doctype_name}.py:
import frappe
import unittest
class TestHDTicket(unittest.TestCase):
def setUp(self):
"""Set up test data"""
self.ticket = frappe.get_doc({
"doctype": "HD Ticket",
"subject": "Test Ticket",
"raised_by": "[email protected]",
"description": "Test description",
})
def test_ticket_creation(self):
"""Test ticket can be created"""
self.ticket.insert()
self.assertTrue(frappe.db.exists("HD Ticket", self.ticket.name))
def test_assign_agent(self):
"""Test assigning agent to ticket"""
self.ticket.insert()
self.ticket.assign_agent("[email protected]")
self.assertEqual(self.ticket.get_assigned_agent(), "[email protected]")
def tearDown(self):
"""Clean up test data"""
if frappe.db.exists("HD Ticket", self.ticket.name):
self.ticket.delete()
bench --site helpdesk.test run-tests --doctype "HD Ticket"