Skip to main content

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 in helpdesk/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"
Or create via code:
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

  1. Validation: Always validate data in validate() method
  2. Permissions: Implement custom permissions for sensitive data
  3. Query Builder: Use Query Builder instead of raw SQL
  4. Error Handling: Use frappe.throw() for user-facing errors
  5. Logging: Use frappe.log_error() for debugging
  6. Real-time: Publish events for UI updates
  7. Child Tables: Use child tables for one-to-many relationships
  8. Virtual Fields: Use @property for computed fields
  9. Transactions: Batch operations and commit manually if needed
  10. Testing: Write unit tests for all business logic

Testing DocTypes

Create tests in test_{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()
Run tests:
bench --site helpdesk.test run-tests --doctype "HD Ticket"

Build docs developers (and LLMs) love