Master Frappe Framework Fundamentals

Add validations, workflows, and background jobs to an asset management app.

Introduction

30 Second Summary

Every business has rules about who can approve what, which items are available, and what happens when something is overdue. The difference between a toy app and a production system is whether those rules are actually enforced by the software.

In this project, you will take your existing Asset Management System built on Frappe Framework and add controller validations, approval workflows, and scheduled background jobs that make it behave like a real enterprise application.

What You'll Build

You will extend your Frappe app with Python controller logic, a multi-step approval workflow, and a daily scheduler task that flags overdue assignments.

By the end of this project, you'll have:

  • Controller validations on BYT Asset, Asset Movement, and Asset Assignment that enforce real business rules like preventing double-assignment.
  • A Workflow on Asset Movement with Draft, Pending Approval, Approved, and Rejected states.
  • A scheduler event that runs daily to flag overdue assignments automatically.
  • Secret Mission: build a Script Report showing asset utilization stats and add system notifications when assets are assigned.

Are there any prerequisites?

This project assumes you already have a Frappe bench running locally on WSL (Ubuntu), with the asset_system app installed on your site mysite, and developer mode enabled.

Explore DocType Anatomy

You have a working Asset Management System with DocTypes, controllers, and API endpoints already in place. Before building new features, you need to understand exactly how Frappe turns a JSON file into a database table, a form, and an API endpoint.

In this step, you will crack open the DocType JSON, inspect the database it generates, and load a document as a Python object. This foundation will make every future step click into place.

In this step, get ready to:
  • Inspect the DocType JSON schema in VS Code.
  • Run bench console commands to see how JSON fields map to MariaDB columns.
  • Load a document as a Python object and understand the Document class.
Open the DocType JSON in VS Code

Every DocType in Frappe is defined by a single JSON file. This file declares every field, its type, its display options, and its behavior. Frappe reads this JSON at runtime to generate forms, database columns, and API endpoints automatically.

  • In VS Code, open the file at this path:
apps/asset_system/asset_system/asset_system/doctype/byt_asset/byt_asset.json
  • Scroll through the file and look for the fields array.
  • Find entries for purchase_value, status, location, and category.
  • Notice each field object has a fieldname (the database column name), a fieldtype (Data, Select, Link, Currency, etc.), and a label (what the user sees in the form).

What is this JSON file doing?

This single file is the entire definition of your BYT Asset DocType. Frappe reads it to create the database table, generate the Desk form, expose REST API endpoints, and enforce field-level rules.

Notice the autoname property set to AST-.####. This tells Frappe to auto-generate names like AST-0001, AST-0002, and so on. The name field is always the primary key of the database table.

Inspect the schema in bench console

Now you will see how Frappe loads this JSON into memory and how it maps to real MariaDB columns. The bench console command opens an interactive Python shell with Frappe already initialized and connected to your database.

  • Open a terminal in your WSL environment and navigate to your frappe-bench folder.
  • Launch the bench console by running this command:
bench --site mysite console

You should see an IPython prompt appear, showing your site is connected.

  • Inspect how Frappe loads the DocType schema into memory by running:
frappe.get_meta('BYT Asset')

This returns a Meta object containing all field definitions, permissions, and naming rules for BYT Asset.

  • Now see the actual database table columns by running:
frappe.db.sql('DESCRIBE `tabBYT Asset`')

What should I see?

The DESCRIBE output shows every column in the tabBYT Asset table. You will see columns like name, creation, modified, owner, and docstatus (standard fields Frappe adds to every DocType), plus your custom fields: purchase_value, purchase_date, status, location, and category.

Each field in the JSON maps 1:1 to a database column. Table names in Frappe are always prefixed with tab, so BYT Asset becomes tabBYT Asset in MariaDB.

Seeing a "site not found" error?

Make sure you specified --site mysite in the console command. If your site has a different name, replace mysite with your actual site name.

Also confirm that bench start is running in a separate terminal window. help me debug a bench console connection error

Load a document as a Python object

The final piece is understanding how Frappe represents a database row as a Python object. When you call frappe.get_doc(), Frappe fetches the row and wraps it in a Document class instance. Every field becomes an attribute on that object.

  • In the same bench console session, load your first asset by running:
frappe.get_doc('BYT Asset', 'AST-0001')

What does this return?

This returns a BYTAsset object (your controller class from byt_asset.py). Every field you defined in the JSON is now an attribute you can access with dot notation, like doc.purchase_value or doc.status.

Link fields (like location or category) store the name of the linked document as a plain string. Select fields (like status) store the selected option as a string value. This Document class is the same base class your controller extends when you add business logic.

  • Try viewing the full dictionary representation by running:
frappe.get_doc('BYT Asset', 'AST-0001').as_dict()

You should see a dictionary with all your custom fields plus standard fields like creation, modified, owner, and docstatus.

Why does this matter?

In the next steps, you will add methods to the BYTAsset class that run automatically when documents are saved or submitted. Those methods use self.purchase_value, self.status, etc. to validate and transform data. Understanding that these attributes come directly from the JSON schema is the key insight.

  • Exit the bench console by typing:
exit()

Getting DoesNotExistError on AST-0001?

If you have not created any BYT Asset documents yet, AST-0001 will not exist. Go to Frappe Desk at http://mysite:8000/app/byt-asset/new and create a test asset first. Then re-run the get_doc command.

help me create a test BYT Asset document in Frappe Desk

You now understand the full anatomy of a DocType: JSON schema to database table to Python object. Next up, you will add controller logic that runs automatically during the document lifecycle.

Add Controller Validations to BYT Asset

You've seen what a DocType looks like at rest. The JSON schema, the database columns, the Python object. Now it's time to make that controller file do real work.

Every Frappe DocType has a controller file where you define Python methods that fire at specific moments during a document's lifecycle. In this step, you'll add validations that enforce business rules, preventing bad data from ever reaching the database.

In this step, get ready to:
  • Add a validate method that blocks invalid saves.
  • Add a before_save method that auto-calculates asset age.
  • Test both validations in Frappe Desk.
Understand the controller lifecycle

Your byt_asset.py file is the controller for the BYT Asset DocType. Right now it contains an empty class that extends Document. Any method you define with a specific name becomes a lifecycle hook that Frappe calls automatically.

  • In VS Code, open apps/asset_system/asset_system/asset_system/doctype/byt_asset/byt_asset.py.

You should see the existing controller code:

from frappe.model.document import Document

class BYTAsset(Document):
    pass

What are lifecycle hooks?

When you save a document in Frappe, the framework calls a series of methods on your controller in a fixed order: before_validate > validate > before_save > db_insert/db_update > on_update.

The validate hook is where you block bad data. If you call frappe.throw() inside it, the save is aborted entirely. The before_save hook runs right after validation passes, making it the perfect place to auto-compute values before they hit the database.

Add the validate method

Your first validation enforces two business rules. Purchase value must be greater than zero. An asset can only be scrapped from "Available" or "Maintenance" status.

  • Replace the entire contents of byt_asset.py with the following code:
import frappe
from frappe.model.document import Document

class BYTAsset(Document):
    def validate(self):
        # Block saves if purchase value is 0 or negative
        if self.purchase_value is not None and self.purchase_value <= 0:
            frappe.throw('Purchase Value must be greater than 0.')

        # Only allow scrapping from Available or Maintenance
        if self.status == 'Scrapped':
            previous_status = frappe.db.get_value('BYT Asset', self.name, 'status')
            if previous_status and previous_status not in ('Available', 'Maintenance'):
                frappe.throw('Asset can only be Scrapped from Available or Maintenance status.')

What does this code do?

  • import frappe gives you access to Frappe's utility functions like throw() and db.get_value().
  • The first if block checks that purchase_value is positive. The is not None check ensures you don't accidentally block saves when the field is left empty.
  • The second block uses frappe.db.get_value() to fetch the asset's current status from the database (before this save). This lets you compare the old status to the new one the user is trying to set.
  • frappe.throw() stops execution entirely and shows an error message to the user. The document is never saved.
  • Save byt_asset.py.
  • In Frappe Desk, navigate to any existing BYT Asset (e.g. AST-0001).
  • Change the Purchase Value field to 0 and click Save.

You should see a red error banner at the top of the form saying "Purchase Value must be greater than 0." The document does not save.

Not seeing the error?

  • Make sure you saved byt_asset.py in VS Code. Frappe reads the file fresh on every request in developer mode.
  • Check for indentation errors. The def validate(self) line must be indented one level inside the class.
  • Confirm you typed import frappe at the very top of the file. Without it, frappe.throw() will raise a NameError.

Help me debug my validate method

Add the before_save method

Validations block bad data. The before_save hook serves a different purpose. It auto-sets computed values every time the document is saved, keeping derived data fresh without manual input.

  • In byt_asset.py, add the following method after the validate method (inside the BYTAsset class):
    def before_save(self):
        # Calculate asset age from purchase date
        if self.purchase_date:
            self.age_in_days = frappe.utils.date_diff(frappe.utils.today(), self.purchase_date)

What does this code do?

frappe.utils.date_diff() calculates the number of days between two dates. Here it subtracts purchase_date from today's date.

The result is stored in self.age_in_days, which maps to the age_in_days field on your DocType. Because this runs on every save, the value stays accurate without anyone manually recalculating it.

  • Save byt_asset.py.
  • In Frappe Desk, set the Purchase Value back to a valid number (e.g. 5000) and click Save.
  • Check the Age In Days field on the form. It should now display a number representing how many days have passed since the asset's purchase date.

Age In Days field not updating?

  • Make sure your asset has a value in the Purchase Date field. The code only runs when self.purchase_date is not empty.
  • Check that the before_save method is indented at the same level as validate (one level inside the class). If it's outside the class, Frappe won't call it.
  • Confirm the age_in_days field exists on your BYT Asset DocType. If it doesn't exist yet, add an Int field named age_in_days via the DocType form in Desk.

Help me fix my before_save method

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete byt_asset.py file:

import frappe
from frappe.model.document import Document

class BYTAsset(Document):
    def validate(self):
        if self.purchase_value is not None and self.purchase_value <= 0:
            frappe.throw('Purchase Value must be greater than 0.')
        if self.status == 'Scrapped':
            previous_status = frappe.db.get_value('BYT Asset', self.name, 'status')
            if previous_status and previous_status not in ('Available', 'Maintenance'):
                frappe.throw('Asset can only be Scrapped from Available or Maintenance status.')

    def before_save(self):
        if self.purchase_date:
            self.age_in_days = frappe.utils.date_diff(frappe.utils.today(), self.purchase_date)

Your BYT Asset now protects itself from invalid data. Next up, you'll use these same controller patterns on your submittable DocTypes to keep related documents in sync when assets move between locations.

Implement the Submit/Cancel Lifecycle on Asset Movement

Your BYT Asset controller now enforces business rules on every save. The next challenge is keeping related documents in sync automatically when something changes.

When an asset physically moves between locations, the asset's own record needs to reflect that new location. In this step, you will use Frappe's submittable document lifecycle to make Asset Movement update the linked BYT Asset automatically on submit, and revert it on cancel.

In this step, get ready to:
  • Understand how the submittable flag changes a DocType's behavior.
  • Implement on_submit and on_cancel hooks that update the asset's location.
  • Test the full Draft > Submit > Cancel cycle in Desk.
Understand the submittable DocType flag

Your Asset Movement DocType has a special property that fundamentally changes how it behaves. The is_submittable flag gives the document a three-stage lifecycle controlled by a field called docstatus.

  • In VS Code, open apps/asset_system/asset_system/asset_system/doctype/asset_movement/asset_movement.json.
  • Search for is_submittable in the file. You should see it set to 1.

What does is_submittable do?

This single flag gives the document three states controlled by docstatus: 0 = Draft (fully editable), 1 = Submitted (locked from editing), 2 = Cancelled. Cancelled documents can be amended, which creates a new linked document via amended_from.

Submitting triggers the before_submit and on_submit hooks. Cancelling triggers before_cancel and on_cancel. These hooks are where you put logic that should only run when a document is finalized or revoked.

Add on_submit and on_cancel hooks to the Asset Movement controller

When an Asset Movement is submitted, the linked BYT Asset's location should update to the destination. When the movement is cancelled, the location should revert to where it came from.

  • In VS Code, open apps/asset_system/asset_system/asset_system/doctype/asset_movement/asset_movement.py.
  • Replace the entire file content with the following code:
import frappe
from frappe.model.document import Document

class AssetMovement(Document):
    def on_submit(self):
        # Fetch the linked asset and update its location to the destination
        asset = frappe.get_doc('BYT Asset', self.asset)
        asset.location = self.to_location
        asset.save()

    def on_cancel(self):
        # Revert the asset's location back to where it came from
        asset = frappe.get_doc('BYT Asset', self.asset)
        asset.location = self.from_location
        asset.save()

What does this code do?

  • on_submit fires only when the document transitions from Draft to Submitted. It fetches the linked BYT Asset using frappe.get_doc(), updates its location field to self.to_location, and saves the asset.
  • on_cancel fires when the document is cancelled. It reverts the asset's location back to self.from_location, undoing the movement as if it never happened.
  • Calling .save() on the asset triggers its own lifecycle hooks (including the validate and before_save methods you wrote earlier).
  • Save asset_movement.py.

Seeing an import error or name not defined?

  • Make sure import frappe is at the top of the file. Without it, frappe.get_doc() will throw a NameError.
  • Check that you removed the pass statement from the class. Python does not need it once the class has methods defined.

Still stuck? Ask for help.

Test the full submit/cancel cycle in Desk

The best way to confirm your hooks work is to walk through the entire lifecycle in Frappe Desk. You will create a movement, submit it, and then cancel it to verify both directions.

  • In your browser, go to http://mysite:8000/app/byt-asset and open an existing asset.
  • Note the current value in the Location field. This is what you will check against after the movement.
  • Navigate to http://mysite:8000/app/asset-movement/new to create a new Asset Movement.
  • Set the Asset field to the asset you just checked.
  • Set From Location to the asset's current location.
  • Set To Location to a different location.
  • Click Save. The document is now in Draft (docstatus 0).
  • Click Submit in the top right, then confirm by clicking Yes.

The document is now Submitted (docstatus 1). Your on_submit hook just ran.

  • Navigate back to the BYT Asset you used. Check the Location field. It should now show the To Location value from your movement.

Now test the cancel path to verify the revert works.

  • Go back to your submitted Asset Movement document.
  • Click Menu (the three dots in the top right), then click Cancel.
  • Confirm by clicking Yes.
  • Navigate back to the BYT Asset. The Location field should now show the original location (the From Location value).

Asset location didn't update?

  • Check the browser for a red error banner after submitting. If you see one, the error message will tell you what went wrong in your controller.
  • Verify self.asset matches the field name in your Asset Movement DocType JSON. Open asset_movement.json and confirm the Link field's fieldname is asset.
  • Make sure you saved the controller file in VS Code before submitting in Desk. Frappe loads the Python file fresh on each request.

Help me debug why my on_submit hook isn't updating the asset location.

✔️ Awesome, I've got everything!

Great. Double check you have saved asset_movement.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete asset_movement.py file:

import frappe
from frappe.model.document import Document

class AssetMovement(Document):
    def on_submit(self):
        # Fetch the linked asset and update its location to the destination
        asset = frappe.get_doc('BYT Asset', self.asset)
        asset.location = self.to_location
        asset.save()

    def on_cancel(self):
        # Revert the asset's location back to where it came from
        asset = frappe.get_doc('BYT Asset', self.asset)
        asset.location = self.from_location
        asset.save()

Your Asset Movement now keeps the BYT Asset's location perfectly in sync through submit and cancel. Next up, you will build the same kind of lifecycle logic on Asset Assignment to control who has the asset and enforce rules against double-assignment.

Enforce Business Rules on Asset Assignment

Your Asset Movement controller now keeps location data in sync automatically. The same pattern applies to Asset Assignment, but with an added challenge.

An asset should only be assignable when it is actually available. If someone tries to assign an asset that is already in use, the system should block them. In this step, you will add controller validations that enforce these business rules and keep asset status consistent.

In this step, get ready to:
  • Add a validation that blocks assignment of unavailable assets.
  • Implement submit and cancel hooks that update asset status automatically.
  • Prevent double-assignment of the same asset.
Validate that the asset is available before assignment

Right now, the Asset Assignment controller is empty. It inherits from Document but has no logic of its own. The first rule to enforce: an asset must have a status of "Available" before anyone can assign it.

  • In VS Code, open asset_assignment.py located at apps/asset_system/asset_system/asset_system/doctype/asset_assignment/asset_assignment.py.
  • Replace the entire file contents with the following code:
import frappe
from frappe.model.document import Document

class AssetAssignment(Document):
    def validate(self):
        # Check that the linked asset is available for assignment
        asset_status = frappe.get_value('BYT Asset', self.asset, 'status')
        if asset_status != 'Available':
            frappe.throw(f'Asset {self.asset} is not Available. Current status: {asset_status}')

What does this code do?

The validate hook runs before every save. It fires whether the document is being saved as a Draft or submitted.

frappe.get_value('BYT Asset', self.asset, 'status') fetches just the status field from the linked asset without loading the entire document into memory. This is more efficient than frappe.get_doc when you only need one field.

frappe.throw() stops execution immediately and shows the error message to the user. The document will not be saved.

  • Save asset_assignment.py.
  • In Frappe Desk, navigate to any BYT Asset and set its status to In Use, then save it.
  • Now go to /app/asset-assignment/new in the URL bar and try to create a new assignment for that asset.
  • Click Save. You should see a red error message saying the asset is not Available.

Not seeing the error message?

  • Make sure you saved asset_assignment.py in VS Code before testing in Desk.
  • Confirm the asset you selected actually has a status other than "Available". Open the asset document in Desk and check.
  • Check that your validate method is indented inside the AssetAssignment class, not outside it.

Help me debug my Asset Assignment validate method

Update asset status on submit and cancel

The validation blocks invalid assignments. Now you need the other half of the flow: when an assignment is submitted, the asset should automatically change to "In Use". When cancelled, it should revert to "Available".

  • In asset_assignment.py, add the following two methods below the validate method:
    def on_submit(self):
        # Set the asset status to In Use when assignment is confirmed
        asset = frappe.get_doc('BYT Asset', self.asset)
        asset.status = 'In Use'
        asset.save()

    def on_cancel(self):
        # Revert asset status to Available when assignment is cancelled
        asset = frappe.get_doc('BYT Asset', self.asset)
        asset.status = 'Available'
        asset.save()

What does this code do?

on_submit fires after the document transitions from Draft (docstatus 0) to Submitted (docstatus 1). This is the same lifecycle hook you used in Asset Movement.

Here, frappe.get_doc loads the full asset document because you need to modify and save it. The .save() call persists the status change to the database and triggers the asset's own validate/before_save hooks.

  • Save asset_assignment.py.
  • In Desk, set your test asset's status back to Available and save it.
  • Create a new Asset Assignment for that asset, fill in the assigned_to and assigned_date fields, and click Save.
  • Click Submit. Open the linked BYT Asset. Its status should now read In Use.
  • Go back to the Asset Assignment and click Menu > Cancel. Check the asset again. Its status should revert to Available.

Asset status not changing after submit?

  • Confirm your on_submit and on_cancel methods are indented at the same level as validate (one level inside the class).
  • Make sure you are clicking Submit, not just Save. The on_submit hook only fires on the Submit action.
  • Check the browser console or terminal for Python tracebacks. A typo in self.asset will cause an error.

Help me debug my on_submit hook

Prevent double-assignment of the same asset

There is still a gap in the logic. If an asset is "Available" and someone submits two assignments for it before either one changes its status, both could go through. You need a check that looks at existing submitted assignments.

  • In asset_assignment.py, find the last line of your validate method:
            frappe.throw(f'Asset {self.asset} is not Available. Current status: {asset_status}')
  • Add the following code below that line, still inside the validate method (same indentation level as the asset_status line):
        # Prevent double-assignment of the same asset
        existing = frappe.db.exists('Asset Assignment', {'asset': self.asset, 'docstatus': 1})
        if existing:
            frappe.throw(f'Asset {self.asset} already has an active assignment: {existing}')

What does this code do?

frappe.db.exists() checks whether a document matching the given filters exists in the database. It returns the document's name if found, or None if not.

The filter 'docstatus': 1 means "only look at submitted documents". Cancelled assignments (docstatus 2) and drafts (docstatus 0) are excluded. This ensures you only block when there is a genuinely active assignment.

  • Save asset_assignment.py.
  • In Desk, make sure your test asset is Available. Create and submit a new Asset Assignment for it.
  • Now try to create a second Asset Assignment for the same asset and click Save. You should see a red error message saying the asset already has an active assignment.

Why two validations catch different cases

The status check catches the common case: someone tries to assign an asset that is clearly "In Use" or "Maintenance". The double-assignment check catches an edge case where the status might still read "Available" but a submitted assignment already exists.

Together, they form a complete guard. No matter how the user tries to create a conflicting assignment, one of these checks will block it.

Second assignment saving without error?

  • Make sure the first assignment was actually submitted (docstatus 1), not just saved as a Draft. The filter checks for 'docstatus': 1.
  • Verify the new code is at the correct indentation level. It should be inside validate but not inside the if asset_status != 'Available' block.

Help me fix my double-assignment validation

✔️ Awesome, I've got everything!

Great. Double check you have saved asset_assignment.py and confirmed all three behaviors work: status validation, submit/cancel status updates, and double-assignment prevention.

ⓧ I'd like to double check the full code

Here is the complete asset_assignment.py file. Make sure yours matches exactly:

import frappe
from frappe.model.document import Document

class AssetAssignment(Document):
    def validate(self):
        asset_status = frappe.get_value('BYT Asset', self.asset, 'status')
        if asset_status != 'Available':
            frappe.throw(f'Asset {self.asset} is not Available. Current status: {asset_status}')
        existing = frappe.db.exists('Asset Assignment', {'asset': self.asset, 'docstatus': 1})
        if existing:
            frappe.throw(f'Asset {self.asset} already has an active assignment: {existing}')

    def on_submit(self):
        asset = frappe.get_doc('BYT Asset', self.asset)
        asset.status = 'In Use'
        asset.save()

    def on_cancel(self):
        asset = frappe.get_doc('BYT Asset', self.asset)
        asset.status = 'Available'
        asset.save()

Your Asset Assignment now enforces real business rules. Assets cannot be assigned unless available, and they cannot be double-assigned. Next up, you will control who can do what with Roles and Permissions.

Configure Roles and Permissions

Your controllers now enforce business rules at the application layer. But what stops someone from bypassing those rules entirely by accessing data they shouldn't touch?

Frappe has a built-in permission system that controls access at every level: the Desk UI, the REST API, and even direct database queries through the ORM. In this step, you will explore how permissions are defined in your DocType JSON, modify them through the Desk UI, and verify they work by testing with a restricted user.

In this step, get ready to:
  • Explore the permissions array in your DocType JSON to understand how roles map to access levels.
  • Use the Role Permissions Manager to remove write access from the Asset Employee role.
  • Create a test user and verify that role-based access works end-to-end.
Explore the permissions array in your DocType JSON

Every DocType JSON file contains a permissions array that declares exactly what each role can do. Frappe reads this array and enforces it automatically on every API call and every Desk interaction.

  • In VS Code, open apps/asset_system/asset_system/asset_system/doctype/byt_asset/byt_asset.json.
  • Scroll down until you find the "permissions" key. It contains an array of objects, one per role.

What does each permission entry mean?

Each object in the permissions array maps a role to a set of access flags: read, write, create, delete, submit, and cancel. A value of 1 grants that permission. A value of 0 (or the key being absent) denies it.

Your app defines three roles: System Manager (full access to everything), Asset Manager (create, edit, submit, cancel), and Asset Employee (read-only). You never have to write permission-checking code in your controllers. Frappe handles it all from this JSON.

Use the Role Permissions Manager to modify permissions

You can also override permissions from the Desk UI without touching the JSON file. The Role Permissions Manager gives you a visual interface to adjust access for any DocType and role combination.

  • In your browser, click on the URL bar in Frappe Desk.
  • Type /app/role-permissions-manager and press Enter.
  • Select BYT Asset from the Document Type dropdown.

You should see a table showing each role and its permission checkboxes (Read, Write, Create, Delete, Submit, Cancel, Amend, and more).

  • Find the Asset Employee row in the table.
  • Uncheck the Write checkbox for Asset Employee.

The change saves automatically. Asset Employee users can now only read BYT Asset documents. They cannot edit any fields.

Why modify permissions here instead of in the JSON?

The DocType JSON defines the default permissions that ship with your app. The Role Permissions Manager lets site administrators customize permissions for their specific deployment without modifying source code. Both approaches are valid, but the JSON is the canonical source for what your app ships with.

Don't see the Asset Employee row?

Make sure you selected BYT Asset (not a different DocType) from the dropdown. If the Asset Employee role does not appear, the role may not have been added to the DocType's permissions array when it was created. You can click Add a New Rule at the bottom of the page and select the Asset Employee role.

Help me troubleshoot why Asset Employee doesn't show in the Role Permissions Manager for BYT Asset.

Create a test user and verify read-only access

The real proof is logging in as a restricted user and seeing the permission system in action. You will create a test user with only the Asset Employee role and confirm they cannot edit assets.

  • In the URL bar, type /app/user/new and press Enter.
  • Enter an email address in the Email field (e.g. testemployee@example.com).
  • Enter a first name in the First Name field (e.g. Test Employee).
  • Scroll down to the Roles table and click Add Row.
  • Select Asset Employee from the role dropdown.
  • Click Save.

Setting a password for the test user

After saving, scroll down to the Set New Password field and enter a password you will remember (e.g. test1234). Click Save again. This lets you log in as this user without needing email verification on a local site.

Now test the permissions by logging in as your new user.

  • Click the user avatar in the top right corner of Desk.
  • Click Log Out.
  • Log in with the test user email and password you just set.
  • Navigate to /app/byt-asset in the URL bar.
  • Open any existing asset document.

You should see the asset form in read-only mode. There is no Save button, no editable fields, and no option to create a new asset. The permission system blocks everything except reading.

  • Log out of the test user account.
  • Log back in as Administrator.

Why didn't you write any permission-checking code?

Frappe enforces the permissions array on every API call, every form load, and every list view automatically. You never need if user.role != 'Admin': return 403 patterns in your controllers.

Your controller hooks (like validate and on_submit) handle business logic. Frappe handles access control. This separation keeps your code focused on the domain problem.

Test user can still edit assets?

Make sure you unchecked Write in the Role Permissions Manager specifically for the Asset Employee role on BYT Asset. Also confirm the test user does not have any other roles (like System Manager) that grant write access.

Help me figure out why my test user with Asset Employee role can still edit BYT Asset documents.

Your permission layer is now locked down. Users can only do what their role allows, and Frappe enforces it everywhere without a single line of access-checking code in your controllers. Next up, you will add an approval workflow to Asset Movement so that moves require sign-off before they take effect.

Build an Approval Workflow for Asset Movement

Your permissions system is working. Roles now control who can read, write, and submit documents across your app.

But real organizations need more than just access control. They need approval processes. An Asset Employee should be able to request a move, but an Asset Manager should have to approve it before the move actually happens. In this step, you will build a multi-step approval Workflow on Asset Movement, entirely through the Frappe UI. No code required.

In this step, get ready to:
  • Create a Workflow with four states on the Asset Movement DocType.
  • Define transitions that control which roles can move documents between states.
  • Test the full approval flow from request to approval, confirming it triggers your controller logic.
Create the Asset Movement Approval workflow

Frappe Workflows override the default Save/Submit behavior of a DocType. Once a Workflow is active, the document no longer shows a Submit button. Instead, it shows action buttons defined by your transitions.

  • In the Frappe Desk URL bar, type /app/workflow/new and press Enter.
  • In the Workflow Name field, enter Asset Movement Approval.
  • In the Document Type field, select Asset Movement.
  • In the Workflow State Field field, enter workflow_state. This tells Frappe which field on the document stores the current workflow state.

Now you need to define the states your Asset Movement can be in. In the States table, add the following four rows:

  • Row 1: State = Draft, Doc Status = 0, style = Primary.
  • Row 2: State = Pending Approval, Doc Status = 0, style = Warning.
  • Row 3: State = Approved, Doc Status = 1, style = Success.
  • Row 4: State = Rejected, Doc Status = 0, style = Danger.

What does Doc Status mean here?

Doc Status maps each workflow state to a document status: 0 = Saved (editable), 1 = Submitted (locked), 2 = Cancelled. Setting "Approved" to Doc Status 1 means that when a manager approves the movement, Frappe automatically submits the document. This triggers your on_submit controller hook that updates the asset's location.

"Draft" and "Pending Approval" stay at Doc Status 0 because the document is still editable and not yet finalized.

Define the workflow transitions

Transitions define which actions move the document from one state to another, and which role is allowed to perform each action.

  • Scroll down to the Transitions table on the same workflow form.
  • Add Row 1: State = Draft, Action = Request Approval, Next State = Pending Approval, Allowed = Asset Employee.
  • Add Row 2: State = Pending Approval, Action = Approve, Next State = Approved, Allowed = Asset Manager.
  • Add Row 3: State = Pending Approval, Action = Reject, Next State = Rejected, Allowed = Asset Manager.
  • Add Row 4: State = Approved, Action = Cancel, Next State = Cancelled, Allowed = Asset Manager.
  • Scroll back up and check the Is Active checkbox. This enables the workflow and deactivates any other workflows on Asset Movement.
  • Click Save.

You should see a green "Saved" confirmation. Your workflow is now active on all Asset Movement documents.

Why does the workflow replace the Submit button?

Creating a Workflow on a submittable DocType overrides the default Save/Submit behavior entirely. The document now functions based on your Workflow transitions, not the pre-set code workflow.

This means learners will no longer see a "Submit" button. Instead, they see the action buttons you defined in your transitions (like "Request Approval"). Frappe handles submission automatically when the document transitions to a state with Doc Status = 1.

Not seeing the workflow form fields?

Make sure you typed /app/workflow/new in the URL bar, not in a search field. The URL should be http://mysite:8000/app/workflow/new.

If the States or Transitions tables are not visible, scroll down. They appear below the main fields.

Help me troubleshoot my Frappe workflow creation form

Test the full approval flow

The workflow is configured. Now you will test the complete cycle to confirm it integrates with your existing controller logic.

  • Navigate to /app/asset-movement/new in the URL bar to create a new Asset Movement.
  • Fill in the Asset, From Location, and To Location fields with valid values from your existing data.
  • Click Save.

Notice the document does not show a Submit button. Instead, you see a Request Approval button. This confirms your workflow is active.

  • Click Request Approval.

The document state changes to "Pending Approval". The status indicator should turn yellow (Warning style).

  • Since you are logged in as Administrator (who has all roles including Asset Manager), you should now see Approve and Reject buttons.
  • Click Approve.

The document state changes to "Approved" with a green indicator. More importantly, the document is now submitted (docstatus = 1). This means your on_submit controller hook has fired.

  • Open the linked BYT Asset document (click the asset link in the movement form).
  • Confirm that the asset's Location field has been updated to the To Location value you entered in the movement.

What just happened end-to-end?

The Approve action transitioned the workflow state to "Approved", which has Doc Status = 1. Frappe interpreted this as a submission event and fired your on_submit method in asset_movement.py. That method fetched the linked BYT Asset and updated its location.

Your workflow and controller hooks work together. The workflow controls WHO can do WHAT and WHEN. The controller controls WHAT HAPPENS when they do it.

Test the Reject path too

Create another Asset Movement and click Request Approval, then click Reject. The document stays at Doc Status 0 (not submitted), so your on_submit hook does NOT fire. The asset location stays unchanged.

Still seeing a Submit button instead of workflow actions?

Make sure the Is Active checkbox is checked on your workflow. Without it, the workflow is saved but not applied.

Also check that the Document Type field says Asset Movement (not "BYT Asset" or another DocType). Try refreshing the page with Ctrl+Shift+R to clear the browser cache.

Help me debug why my Frappe workflow is not showing action buttons on Asset Movement

Asset location did not update after approving?

Open the Asset Movement document and check the docstatus value in the URL or via bench console. It should be 1 after approval. If it is still 0, your "Approved" state's Doc Status is not set to 1 in the workflow. Go back to /app/workflow/Asset Movement Approval and fix the states table.

Why isn't my on_submit hook firing when the workflow approves my Asset Movement?

You just built a complete approval flow without writing a single line of workflow code. Your Workflow handles the state machine, and your controller hooks handle the business logic when state changes happen. Next up, you will add automated background tasks that run on a schedule.

Automate Background Tasks with Scheduler Events

Your Asset Management System now enforces business rules through controllers and requires approvals through workflows. But what about tasks that need to happen automatically, without anyone clicking a button?

Frappe's Scheduler lets you run Python functions on a schedule (daily, hourly, weekly) using background workers powered by Redis Queue. In this step, you will create a scheduled job that automatically flags overdue asset assignments.

In this step, get ready to:
  • Register a scheduler event in your app's hooks.py file.
  • Create a background task that finds overdue Asset Assignments and adds comments to them.
  • Run bench migrate and manually test your scheduled function.
Register a daily scheduler event in hooks.py

Frappe's hooks.py file is the central registry for your app's extensions. By adding a scheduler_events dictionary, you tell Frappe to run specific functions on a recurring schedule.

  • In VS Code, open apps/asset_system/asset_system/hooks.py.
  • Scroll to the bottom of the file and add this scheduler configuration:
# Schedule background tasks to run automatically
scheduler_events = {
    'daily': ['asset_system.tasks.flag_overdue_assignments']
}

What does this code do?

The scheduler_events dictionary maps a frequency to a list of dotted Python paths. The 'daily' key means Frappe will call this function once per day automatically.

Other available frequencies include hourly, weekly, monthly, and all (which runs every 60 seconds). The dotted path asset_system.tasks.flag_overdue_assignments tells Frappe exactly which module and function to execute.

  • Save hooks.py.

You will not see a change yet. The function this hook points to does not exist yet, so the next substep creates it.

Why use hooks instead of a cron job?

Frappe's scheduler hooks are integrated with its background worker system (Python RQ backed by Redis). This means your scheduled tasks automatically get error logging, retry logic, and access to the full Frappe API context.

A raw cron job would need to bootstrap the Frappe environment manually and would not appear in the Scheduler Log in Desk.

Create the flag_overdue_assignments task

The scheduler hook points to asset_system.tasks.flag_overdue_assignments, which means Frappe expects a file called tasks.py at the module root of your app.

  • In VS Code, create a new file at apps/asset_system/asset_system/tasks.py.
  • Add the following code to define your scheduled function:
import frappe

def flag_overdue_assignments():
    # Find all submitted assignments where the assigned_date is more than 30 days ago
    overdue = frappe.get_all(
        'Asset Assignment',
        filters={
            'docstatus': 1,
            'assigned_date': ['<', frappe.utils.add_days(frappe.utils.today(), -30)]
        },
        fields=['name', 'asset', 'assigned_to']
    )
    # Add a comment to each overdue assignment as a flag
    for assignment in overdue:
        doc = frappe.get_doc('Asset Assignment', assignment['name'])
        doc.add_comment('Comment', 'This assignment is overdue (30+ days)')
    # Commit all changes to the database
    frappe.db.commit()

What does this code do?

  • frappe.get_all() queries the database for all Asset Assignments that are submitted (docstatus: 1) and have an assigned_date older than 30 days from today.
  • frappe.utils.add_days(frappe.utils.today(), -30) calculates the date 30 days in the past, which is used as the comparison threshold.
  • doc.add_comment('Comment', ...) appends a visible comment to the document's activity timeline, making it easy to spot overdue items in Desk.
  • frappe.db.commit() saves all changes to the database. Background tasks do not auto-commit like web requests do, so this line is essential.
  • Save tasks.py.

The file is saved, but Frappe does not know about the new hook yet. The next substep registers it.

Not sure where to create the file?

The file path is apps/asset_system/asset_system/tasks.py. This sits at the same level as hooks.py in your app's module root. If you placed it inside the doctype folder, the dotted path will not resolve correctly.

help me find the right location for tasks.py in my Frappe app

Register the hook and test your scheduled task

Changes to hooks.py are not picked up automatically. You need to run bench migrate so Frappe re-reads and registers the updated hooks.

  • In your WSL terminal, make sure you are in the frappe-bench directory.
  • Register the hook changes by running:
bench --site mysite migrate
  • You should see output showing the migration completes without errors. Look for a line that ends with Success or the command returning to your prompt cleanly.

Seeing a ModuleNotFoundError?

If the error mentions asset_system.tasks, your tasks.py file is not in the correct directory. It must be at apps/asset_system/asset_system/tasks.py (the same folder as hooks.py).

help me fix this ModuleNotFoundError during bench migrate

Now test the function manually instead of waiting 24 hours for the daily schedule to trigger.

  • Run your scheduled function directly by executing:
bench --site mysite execute asset_system.tasks.flag_overdue_assignments

What does bench execute do?

The bench execute command runs any Python function in the Frappe context immediately. It is the standard way to test scheduler tasks without waiting for the actual schedule to fire.

  • Open Frappe Desk in your browser at http://mysite:8000.
  • Navigate to any submitted Asset Assignment that has an assigned_date older than 30 days.
  • Scroll down to the comment section. You should see a new comment that reads This assignment is overdue (30+ days).

No overdue assignments to test with?

If none of your Asset Assignments are older than 30 days, create a test one and manually set the assigned_date to a date more than 30 days in the past before submitting it. Then re-run the bench execute command.

No comments appearing on the assignment?

Check that the assignment is submitted (docstatus = 1) and that its assigned_date is genuinely more than 30 days before today. Draft or cancelled assignments will not be picked up by the query.

Also confirm the bench execute command completed without errors. If you see a traceback, the function has a syntax or logic issue.

help me debug why my scheduled task is not adding comments

✔️ Awesome, I've got everything!

Great. Double check you have saved both hooks.py and tasks.py.

ⓧ I'd like to double check the full code

Here is the complete tasks.py file:

import frappe

def flag_overdue_assignments():
    overdue = frappe.get_all(
        'Asset Assignment',
        filters={
            'docstatus': 1,
            'assigned_date': ['<', frappe.utils.add_days(frappe.utils.today(), -30)]
        },
        fields=['name', 'asset', 'assigned_to']
    )
    for assignment in overdue:
        doc = frappe.get_doc('Asset Assignment', assignment['name'])
        doc.add_comment('Comment', 'This assignment is overdue (30+ days)')
    frappe.db.commit()

And here is the addition to the bottom of hooks.py:

scheduler_events = {
    'daily': ['asset_system.tasks.flag_overdue_assignments']
}

Your Asset Management System now runs background automation. Every day, Frappe will check for overdue assignments and flag them without any human intervention. Next up, you will extend the app even further in the Secret Mission with reports and real-time notifications.

Secret mission

Build a Script Report and Add System Notifications

Your Asset Management System enforces business rules and approval workflows. But a production app also needs reporting and real-time alerts. In this secret mission, you will build a Script Report that shows asset utilization stats and wire up system notifications so users know the moment an asset is assigned to them.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally on your WSL environment, so there are no ongoing cloud costs.

Resources you used:

  • Frappe bench and site (mysite) running on WSL.
  • Controller files: byt_asset.py, Asset Movement controller, Asset Assignment controller.
  • Workflow: Asset Movement Approval (configured in Desk).
  • Scheduler task file: asset_system/tasks.py.
  • Hooks entry in hooks.py for scheduler events.
  • Test user with Asset Employee role.
  • Test documents: BYT Assets, Asset Movements, Asset Assignments.

Keep everything running

No action needed. Choose this if you are still actively building on your Asset Management System or plan to continue with the Secret Mission.

  • Your bench and site will remain as-is when you stop bench start.
  • All controller code, workflows, and scheduler events stay in place.
  • Next time you want to work on the project, open WSL and run bench start from your bench directory.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database stay intact on disk.

  • Stop bench start by pressing Ctrl+C in the terminal.
  • Optionally stop Redis and MariaDB services to free memory. They restart automatically next time you run bench start.
  • Your site data, controller code, workflow, and scheduler configuration all persist on disk.

Delete - I don't want to use this again

Remove all project resources and start fresh. Only do this if you are certain you will not return to this project.

  • Delete the test user you created from the User list in Desk.
  • Delete the workflow by navigating to /app/workflow/Asset Movement Approval and clicking Menu > Delete.
  • Remove test documents (Asset Movements, Asset Assignments, BYT Assets) through Desk.
  • To remove the entire app and site, run bench drop-site mysite and then delete the asset_system folder from apps/.

That's a Wrap!

That's a Wrap!

Nice work! You have taken your Asset Management System from a basic CRUD app to a production-grade Frappe application with enforced business rules, approval workflows, and automated background tasks.

You now understand Frappe Framework from the ground up. Every layer, from data modeling to business logic to access control to automation.

Here's what you accomplished:

  • Built controller validations that enforce real business rules on Asset Assignment and Asset Movement, preventing double-assignments and invalid status transitions.
  • Configured a multi-step approval Workflow on Asset Movement with Draft, Pending Approval, Approved, and Rejected states entirely through the Desk UI.
  • Implemented a scheduled background job using hooks and scheduler events that runs daily to flag overdue assignments automatically.
  • Built a Script Report for asset utilization stats and added system notifications for real-time alerts when assets are assigned or movements are approved.

Ready to quiz yourself?