[Rt-commit] rt branch, 4.4/core-assets, repushed

Todd Wade todd at bestpractical.com
Fri Sep 11 09:20:33 EDT 2015


The branch 4.4/core-assets was deleted and repushed:
       was bc64c0a30e9de997542549e43129e66b169aa939
       now 791cad5ff3be5e2de6ded42cae2f5a25764fd065

1:  cdabdbc < -:  ------- commit rt-extension-assets/xt as rt/t/assets
2:  6eb0520 < -:  ------- remove .in file and use .pm file as test lib
3:  422e089 < -:  ------- move extension test lib in to RT
4:  322fbe2 < -:  ------- clean up RT::Test::Asset lib so it can be used as a base for the tests
5:  3b71f69 < -:  ------- move assets database scaffolding to RT
6:  0f24385 < -:  ------- assets tests from rt-extension-assets
7:  9a64b0d ! 1:  791cad5 core RT::Extension::Assets
    @@ -1,6 +1,3781 @@
     Author: Todd Wade <todd at bestpractical.com>
     
    -    copy rt-extension-assets/html/* in to share/html
    +    core RT::Extension::Assets
    +
    +diff --git a/.gitignore b/.gitignore
    +--- a/.gitignore
    ++++ b/.gitignore
    +@@
    + /etc/upgrade/switch-templates-to
    + /etc/upgrade/time-worked-history
    + /etc/upgrade/upgrade-articles
    ++/etc/upgrade/upgrade-assets
    + /etc/upgrade/vulnerable-passwords
    + /lib/RT/Generated.pm
    + /Makefile
    +
    +diff --git a/configure.ac b/configure.ac
    +--- a/configure.ac
    ++++ b/configure.ac
    +@@
    +                  etc/upgrade/switch-templates-to
    +                  etc/upgrade/time-worked-history
    +                  etc/upgrade/upgrade-articles
    ++                 etc/upgrade/upgrade-assets
    +                  etc/upgrade/vulnerable-passwords
    +                  sbin/rt-attributes-viewer
    +                  sbin/rt-preferences-viewer
    +
    +diff --git a/docs/customizing/assets_introduction.pod b/docs/customizing/assets_introduction.pod
    +new file mode 100644
    +--- /dev/null
    ++++ b/docs/customizing/assets_introduction.pod
    +@@
    ++
    ++=head1 CONFIGURATION
    ++
    ++See L<RT_Config.pm> for documentation on the available configuration
    ++parameters.
    ++
    ++=head1 USAGE
    ++
    ++Assets start as a small set of fundamental record data upon which you build
    ++custom fields (CFs) of any type you like to describe the assets you want to
    ++track.  By themselves, before you setup any CFs, assets are not very useful.
    ++
    ++Just like tickets are assigned to queues, assets are assigned to B<catalogs>.
    ++The default catalog is named "General assets", but we suggest you rename it and
    ++add additional catalogs to better fit your organization.
    ++
    ++You may want to use catalogs to separate assets into departments, general type
    ++of asset, hardware vs. software, etc.  Catalogs, like queues, are generally
    ++easiest to work with when there's more than few but less than many dozen.  The
    ++catalog of an asset should represent some fundamental quality of it (and many
    ++other assets!), that could just as easily be expressed as a custom field, but
    ++which is more important than other qualities for categorizing, sorting, and
    ++searching.
    ++
    ++=head2 Managing catalogs
    ++
    ++Catalogs are managed by RT administrators, or anyone with the L</AdminCatalog>
    ++right.  You can find the list of catalogs, create new catalogs, and manage
    ++existing ones under the Tools → Configuration → Assets → Catalogs menu.
    ++
    ++Currently you need to log out and log back in to see changes to catalogs in any
    ++of the catalog selection dropdowns.  This doesn't affect the catalog name
    ++displayed on individual asset pages.
    ++
    ++=head2 Adding fields
    ++
    ++You can see the current asset CFs by navigating to Admin >
    ++Assets > Custom Fields.  From there you can use the "Create" link to create a
    ++new asset CF.  If you know you want to create a new CF right away, you can do
    ++so via Admin > Assets > Custom Fields > Create.
    ++
    ++When creating a CF, be sure to select "Assets" in the "Applies To" dropdown.
    ++You'll also need to grant rights to the groups and/or roles which need to see
    ++the fields, otherwise they'll be hidden.  See the following section.
    ++
    ++Similar to ticket CFs, asset custom fields are added globally or to specific
    ++catalogs.  Only assets within those specific catalogs will have the CFs
    ++available.  After creating a CF, you'll need to visit the "Applies To" page to
    ++add it to the catalogs you want or make it global.
    ++
    ++=head2 Rights
    ++
    ++There are three rights controlling basic access to assets and two for
    ++catalogs.  Each right is grantable at the global level or individual catalog
    ++level, and grantable to system groups, asset roles, user groups, and individual
    ++users (just like ticket and queue rights).
    ++
    ++=head3 ShowAsset
    ++
    ++Allows viewing an asset record and it's core fields (but not CFs).  Without
    ++it, no assets can be seen.  Similar to ShowTicket.
    ++
    ++=head3 CreateAsset
    ++
    ++Allows creating assets and filling in the core fields (but not CFs).  Without
    ++it, no assets can be created.  Similar to CreateTicket.
    ++
    ++=head3 ModifyAsset
    ++
    ++Allows modifying existing assets and their core fields (but not CFs).  Without
    ++it, basic asset data cannot be modified after creation.  Similar to
    ++ModifyTicket.
    ++
    ++Most of your rights configuration will be on the CFs, and will likely need to
    ++be done for each CF.  This lets you fine tune which fields are visible to
    ++individual groups and/or roles of users.  Relevant CF rights are
    ++B<SeeCustomField> and B<ModifyCustomField>.
    ++
    ++Rights related to assets may also come from the L</Lifecycle statuses>
    ++configuration and restrict status transitions.
    ++
    ++=head3 ShowCatalog
    ++
    ++Allows seeing a catalog's name and other details when associated with assets.
    ++Without it, users will see "[a hidden catalog]" or a blank space where the
    ++catalog name would normally be.  Similar to SeeQueue.
    ++
    ++=head3 AdminCatalog
    ++
    ++Allows creating new catalogs and modifying all aspects of existing catalogs,
    ++including changing the CFs associated with the catalog, granting/revoking
    ++rights, and adding/removing role members.  This right should only be granted to
    ++administrators of RT.  Similar to AdminQueue.
    ++
    ++=head3 Typical configuration
    ++
    ++A typical configuration grants the system Privileged group the following:
    ++B<ShowAsset>, B<CreateAsset>, B<ModifyAsset>, and B<ShowCatalog> globally, and
    ++B<SeeCustomField> and B<ModifyCustomField> globally on all asset CFs.
    ++
    ++If you want self service users (Unprivileged) to be able to view the assets
    ++they hold, grant the Held By role B<ShowAsset> and B<ShowCatalog> globally and
    ++B<SeeCustomField> on the necessary asset CFs.
    ++
    ++=head2 People and Roles
    ++
    ++Just like tickets, assets have various roles which users and groups may be
    ++assigned to.  The intended usages of these roles are described below, but
    ++you're free to use them for whatever you'd like, of course.
    ++
    ++The roles provide ways to keep track of who is involved with each asset, as
    ++well as providing a place to grant rights that depend on the user's association
    ++with each asset.
    ++
    ++In addition to adding people to individual asset roles, you can also add role
    ++members at an entire catalog level.  These catalog-level roles are useful in
    ++cases when you might have an entire catalog of assets for which the same people
    ++should be the Contacts, or which are Held By the same group.  Unlike tickets
    ++where the queue watchers are invisible, catalog role members are visible
    ++because assets are generally much longer lived than tickets.  When a problem
    ++with an asset arises, it's easier to see who to create a ticket for.  On
    ++individual asset pages, catalog role members are shown with the text "(via this
    ++asset's catalog)" following each name.
    ++
    ++=head3 Owner
    ++
    ++The person responsible for the asset, perhaps the purchaser or manager.
    ++
    ++Restricted to a single user.  Not available at a catalog level.
    ++
    ++=head3 Held By
    ++
    ++The person or people who physically possess the asset or are actively using the
    ++asset (if it isn't physical).  This may be the same as the Contacts or may be
    ++different.  For example, a computer workstation may be "held by" a university
    ++professor, but the contact may be the IT staff member responsible for all
    ++assets in the professor's department.  This role is most similar to Requestor
    ++on tickets, although not equivalent.
    ++
    ++May be multiple users and/or groups.
    ++
    ++=head3 Contact
    ++
    ++The person or people who should be contacted with questions, problems,
    ++notifications, etc. about the asset.  Contacts share some of the same intended
    ++usages of both Requestors and Ccs on tickets.
    ++
    ++May be multiple users and/or groups.
    ++
    ++=head2 Lifecycle statuses
    ++
    ++One of the basic asset fields is "Status".  Similar to tickets, the valid
    ++statuses and their transitions and actions can be customized via RT's standard
    ++Lifecycles configuration (see "Lifecycles" in F<RT_Config.pm>).  The default
    ++lifecycle is named "assets".  You're free to modify it as much as you'd like,
    ++or add your own lifecycles.  Each catalog may have its own lifecycle.
    ++
    ++For the default "assets" configuration, see F<etc/Assets_Config.pm>.
    ++
    ++=head2 Field organization
    ++
    ++=head3 Groupings
    ++
    ++You can organize your asset CFs into visual and logical "groupings" as you see
    ++fit.  These groupings appear as separate boxes on the asset display page and
    ++become separate pages for editing (showing up in the per-asset menu).
    ++
    ++By default your CFs will appear in a B<Custom Fields> box on the asset display
    ++page and will be editable from a box of the same name on the B<Basics> editing
    ++page.
    ++
    ++Using the C<%CustomFieldGroupings> option (documented in F<etc/RT_Config.pm>),
    ++you can move individual CFs by name into one of the four built-in groupings
    ++(B<Basics>, B<People>, B<Dates>, and B<Links>) or create your own just by
    ++naming it.  An example, assuming a date CF named "Purchased" and two "enter one
    ++value" CFs named "Weight" and "Color":
    ++
    ++    # In etc/RT_SiteConfig.pm
    ++    Set(%CustomFieldGroupings,
    ++        'RT::Asset' => {
    ++            'Dates'                 => ['Purchased'],
    ++            'Physical Properties'   => ['Weight', 'Color'],
    ++        },
    ++    );
    ++
    ++This configuration snippet will move all three CFs out of the generic B<Custom
    ++Fields> box and into the B<Dates> box and a new box titled B<Physical
    ++Properties>.  The "Purchased" CF will be editable from the Dates page and a new
    ++page titled "Physical Properties" will appear in the menu to allow editing of
    ++the "Weight" and "Color" CFs.
    ++
    ++=head3 Ordering
    ++
    ++Within a box, CFs come after any built-in asset fields such as Name,
    ++Description, Created, Last Updated, etc.  The CFs themselves are ordered
    ++according to the sorting seen (and adjustable) on the global Asset Custom
    ++Fields page (Tools → Configuration → Global → Custom Fields → Assets) and the
    ++individual catalog Custom Fields pages (Tools → Configuration → Assets →
    ++Catalogs → (Pick one) → Custom Fields).
    ++
    ++Global asset CFs may be intermixed with per-catalog CFs with ordering.
    ++
    ++=head2 Importing existing data
    ++
    ++Another extension, L<RT::Extension::Assets::Import::CSV> provides tools
    ++to import new and update existing assets from a CSV dump.  Its
    ++configuration lets you map the fields in the CSV to the asset fields
    ++you've already created in RT.  L<RT::Extension::Assets::AppleGSX> also
    ++provides tools for looking up data associated with an Apple product.
    ++
    ++=cut
    +
    +diff --git a/docs/customizing/assets_tutorial.pod b/docs/customizing/assets_tutorial.pod
    +new file mode 100644
    +--- /dev/null
    ++++ b/docs/customizing/assets_tutorial.pod
    +@@
    ++
    ++=head1 Introduction
    ++
    ++This is a basic tutorial for setting up asset tracking in RT using Best
    ++Practical's Assets extension. At the end, you'll have a basic configuration
    ++that lets you add assets, search for them, link them to tickets, etc.
    ++
    ++=begin HTML
    ++
    ++<p><img src="http://static.bestpractical.com/images/assets/asset-search.png"
    ++alt="Asset Search Results" /></p>
    ++
    ++=end HTML
    ++
    ++You can follow along with the tutorial and try setting things up yourself to
    ++get a feel for all of the administrative controls. If you want to get a jump
    ++start, the files to set up this basic configuration are provided in the
    ++L<RT::Extension::Assets> distribution in the F<etc> directory. For
    ++configuration, look in F<etc/Tutorial_Configuration.txt>. You can copy all or
    ++part of it and paste it into your F<RT_SiteConfig.pm>.
    ++
    ++To load the test catalog, custom fields, and users, follow the
    ++installation instructions in L<RT::Extension::Assets/INSTALLATION>, then
    ++run the following from your RT directory:
    ++
    ++    sbin/rt-setup-database --action insert --datafile \
    ++      local/plugins/RT-Extension-Assets/etc/tutorialdata
    ++
    ++This will change the default catalog name, create some users, and give those
    ++users asset permissions. Only asset rights are granted, so you need to grant
    ++additional rights if you want to experiment with creating tickets and linking
    ++assets. The initial user passwords are 'password'. You should only run this on
    ++a test RT instance, as it is not intended to be used for configuring a
    ++production system.
    ++
    ++=head1 Getting Started
    ++
    ++Install the extension following the instructions and some new tables will be
    ++added to your RT database and the assets code will be installed. As with all
    ++extensions, first add C<RT::Extension::Assets> to your C<@Plugins> line.
    ++
    ++There are a few configuration options you might set before starting. Assets
    ++offers a C<$DefaultCatalog> feature that works similar to RT's
    ++L<DefaultQueue|http://bestpractical.com/docs/rt/latest/RT_Config.html#DefaultQueue>,
    ++but you can probably skip it for now since you don't have any catalogs yet.
    ++
    ++More interesting are some optional portlets you can activate to add asset data
    ++to RT's pages. MyAssets and FindAsset portlets are available for placement on
    ++RT at a Glance or in dashboards and a UserAssets portlet is available for the
    ++user summary pages.
    ++
    ++These portlets are fairly self-explanatory and you can add them by finding
    ++C<$HomepageComponents> and C<@UserSummaryPortlets> respectively in
    ++F<RT_Config.pm>, copying to F<RT_SiteConfig.pm>, and adding the portlets you
    ++want. There are also examples in the tutorial sample configuration file. Note
    ++that C<$HomepageComponents> makes the portlets available, but doesn't put them
    ++on RT at a Glance. To add them, just click the Edit link on the upper right-hand
    ++corner of the homepage. C<@UserSummaryPortlets> does automatically add the
    ++"Assigned Assets" portlet to the User Summary page. It will appear based on the
    ++position in the configuration, so just place it in the list where you want it
    ++to appear.
    ++
    ++Once you have your configuration complete, restart your server and you're ready
    ++to go.
    ++
    ++=head1 Catalogs
    ++
    ++For the initial configuration, we'll log in as RT's root user so we have full
    ++rights on the asset configuration. You could also create an "Asset Admin" group
    ++and assign appropriate rights to allow other users to manage catalogs.
    ++
    ++When you log in you'll see a new Assets menu, but before looking there we need
    ++to look at catalogs and some other configuration. Catalogs are to assets what
    ++queues are to tickets, so if you've used RT, the relationship should be fairly
    ++familiar. Similar to the General queue, a "General assets" catalog is provided
    ++to get you started. You can see it at Admin > Assets > Catalogs.
    ++
    ++We're going to use the default, but change it to a name more appropriate for
    ++our use. Clicking on the asset name brings us to the catalog edit page and we
    ++can update the name to "IT Department Assets". You can update the description
    ++if you like as well.
    ++
    ++=begin HTML
    ++
    ++<p><img src="http://static.bestpractical.com/images/assets/edit-catalog.png"
    ++alt="Edit Catalog" /></p>
    ++
    ++=end HTML
    ++
    ++You'll also notice that catalogs have a lifecycle just like queues. The assets
    ++extension comes with a default assets lifecycle, but just like queues you can
    ++create new ones with custom statuses and other configuration to allow RT to
    ++reflect the states of your assets.
    ++
    ++You can find the asset lifecycle in the asset configuration file in your RT
    ++installation at:
    ++
    ++    local/plugins/RT-Extension-Assets/etc/Assets_Config.pm
    ++
    ++The initial statuses are new, allocated, in-use, recycled, stolen, and deleted.
    ++Depending on your process, you might add new ones like surplussed, donated, or
    ++in-repair. To create a new asset lifecycle, just copy the default into
    ++F<RT_SiteConfig.pm>, replace the top-level "assets" key with a new name, and
    ++make your changes.
    ++
    ++=head1 Asset Custom Fields
    ++
    ++Next we need to create some custom fields to hold our asset metadata. You can
    ++find asset custom fields at Admin > Assets > Custom Fields and they work just
    ++like custom fields for other RT objects.
    ++
    ++=begin HTML
    ++
    ++<p><img src="http://static.bestpractical.com/images/assets/asset-cfs.png"
    ++alt="Asset Custom Fields" /></p>
    ++
    ++=end HTML
    ++
    ++The extension will automatically provide some core values for your assets. Each
    ++asset can have a Name and Description and, like tickets, they have statuses
    ++based on the lifecycle configuration. You can use Name and Description however
    ++you want and they are not required. However, many of the asset pages use these
    ++fields so it's best to provide a descriptive name to make it easy for people
    ++working in RT to identify the asset quickly. The manufacturer's product name
    ++can be convenient (e.g., '15" Macbook Pro').
    ++
    ++Assets come with three user fields you can associate with an asset: Owner, Held
    ++By, and Contact. These are provided to cover different types of assets, from
    ++laptops to servers to software, and different asset management situations.
    ++Owner can hold the user who bought the asset, maybe the head of the department
    ++where the budget came from. Held by is who the asset is assigned to. Laptops
    ++are assigned to a user and servers might be held (or managed) by the system
    ++administrators. Contact can be used to set a manager who might need to know
    ++about needed system updates or equipment with expiring support. Like tickets,
    ++these roles give you places to attach rights, so use them however they work
    ++best for you.
    ++
    ++Any other information you want to track will need custom fields. We'll start
    ++with a few basic fields:
    ++
    ++=over
    ++
    ++=item * Serial Number (enter one value)
    ++
    ++The serial number from the asset.
    ++
    ++=item * Tracking Number (enter one value)
    ++
    ++An internal tracking number. RT will assign an asset ID as well, but you may
    ++have other systems to integrate with or already have a way to assign asset ids
    ++for accounting purposes.
    ++
    ++=item * Manufacturer (dropdown)
    ++
    ++Company that made the asset.
    ++
    ++=item * Type (dropdown)
    ++
    ++Is it a laptop, server, or cell phone?
    ++
    ++=item * Issue Date (date)
    ++
    ++When the asset was given to the owner (or held by) person.
    ++
    ++Assets keep a transaction history like tickets, so you may be able pull this
    ++information from the "owner set to X" transaction. Creating a separate field
    ++makes it easier to report on.
    ++
    ++=item * Support Expiration (date)
    ++
    ++When the current support contract expires.
    ++
    ++=back
    ++
    ++=head1 Custom Field Grouping
    ++
    ++Any custom fields you create will be displayed on the asset display page in a
    ++default "Custom Fields" section. That may be sufficient, but assets also
    ++supports RT's new custom field grouping feature, so we can group together some
    ++similar custom fields and give them a custom name. If we add the following to
    ++F<RT_SiteConfig.pm>:
    ++
    ++    Set(%CustomFieldGroupings,
    ++        'RT::Asset' => {
    ++            'Asset Details' => ['Serial Number', 'Manufacturer', 'Type', 'Tracking Number'],
    ++            'Dates'         => ['Support Expiration', 'Issue Date'],
    ++        },
    ++    );
    ++
    ++and restart RT, the dates will be tacked on the end of the Dates portlet and we
    ++get an Asset Details label on the other custom fields.
    ++
    ++=begin HTML
    ++
    ++<p><img
    ++src="http://static.bestpractical.com/images/assets/asset-date-details.png"
    ++alt="Asset Date and Details Display" /></p>
    ++
    ++=end HTML
    ++
    ++=head1 Asset Rights
    ++
    ++Now we've got the basic configuration in place to start recording asset data.
    ++Next we need to assign some rights so people can view and edit asset
    ++information. Our staff are all privileged users so we'll grant all view and
    ++modify rights on our catalog to the Privileged role. We'll also include rights
    ++to view and modify the catalog's custom fields, although you could set these
    ++rights individually on each custom field if you wanted to allow users to see
    ++some but not others.
    ++
    ++Similar to queues, you can set rights at the catalog level. Go to Admin >
    ++Assets > Catalogs and click on the catalog you want to edit. Click Group Rights
    ++in the submenu to assign asset rights to groups like the system Privileged
    ++group.
    ++
    ++=begin HTML
    ++
    ++<p><img src="http://static.bestpractical.com/images/assets/catalog-rights.png"
    ++alt="Catalog Rights" /></p>
    ++
    ++=end HTML
    ++
    ++We also want unprivileged users to be able to see their own assets to make it
    ++easier to submit support requests. To give them just the Name and Description
    ++on their own assets, we can grant SeeAssets and SeeCatalogs on the catalog to
    ++the Held By role.
    ++
    ++All of the asset rights are described in the Assets documentation. You can get
    ++much more detailed and fine-grained than this example, allowing selected groups
    ++and users to view and modify multiple different asset custom fields across many
    ++different catalogs.
    ++
    ++=head1 Working with Assets
    ++
    ++So now that we have all of that configuration done, what can we do? Here are a
    ++few scenarios to give you some ideas.
    ++
    ++=head2 Add Assets to Your Catalogs
    ++
    ++To start, staff can now start adding assets to RT allowing you to manage what
    ++you have, what state it's in, who currently has it, and when support expires.
    ++You could set up an intake process to get new assets added as they come in, and
    ++eventually have statuses updated as they are assigned, used, and eventually
    ++cycled out.
    ++
    ++If you already have an asset database, even something simple like a
    ++spreadsheet, you may be able to do an initial bulk import. Best Practical has
    ++released L<RT::Extension::Assets::Import::CSV> which is a CSV import tool to
    ++help you with this.
    ++
    ++=head2 Track Assets
    ++
    ++Your staff can now easily track work on assets by linking RT tickets to the
    ++assets. Assume you have an issue with an asset, like a server needs a new power
    ++supply. Your staff can use the asset search page to find the server. You'll
    ++notice that the RT search box is context sensitive, so when you're on an asset
    ++page, the search changes to Search Assets and you can search with that as well.
    ++
    ++Once you locate the server asset record, in the Actions menu you'll find
    ++"Create linked ticket", which does just that. You select the queue and which
    ++user to use from the asset as the Requestor, and you land on the ticket create
    ++page with some information pre-filled.
    ++
    ++=begin HTML
    ++
    ++<p><img
    ++src="http://static.bestpractical.com/images/assets/asset-ticket-create.png"
    ++alt="Create Ticket for Asset Work" /></p>
    ++
    ++=end HTML
    ++
    ++As you can see in the screenshot, when you create a ticket with a linked asset,
    ++you get an asset portlet on the create page and on the ticket display page as
    ++well. If you navigate back to the asset, you'll see a link back to the ticket
    ++in the Links section there. This gives you a record of all the tickets that
    ++have been opened against this asset. If this is a common scenario for you, you
    ++might even add a custom field on the ticket with the vendor tracking number of
    ++the repair. During the repair, you might flip the asset to an 'in-repair'
    ++status. Then when the ticket is resolved, flip it back to 'in-use'.
    ++
    ++=head2 End User Asset Tickets
    ++
    ++If an end user contacts us with some problems with their laptop, RT makes it
    ++easy to find the correct laptop record and create a ticket for them. Since our
    ++support staff do this frequently, they have added the Find User portlet to
    ++their RT at a glance page and can quickly search for the user and go to their
    ++User Summary page (new in RT 4.2).
    ++
    ++We have added the Assigned Assets portlet to the User Summary page, so the
    ++laptop is right there on the page when we find the user. We can just click on
    ++the asset, then use the "Create linked ticket" action as before to create the
    ++new repair ticket.
    ++
    ++=head2 End User Self Service
    ++
    ++Assume we already assign passwords to our unprivileged users so they can use
    ++RT's self service interface to submit tickets and they have basic permissions
    ++to do so (SeeQueue on the designated queue, CreateTicket, etc.). Since we've
    ++given some asset rights to unprivileged users, they can use RT's Self Service
    ++interface to find their assets (e.g., laptops, cell phones, etc.) when
    ++submitting support requests.
    ++
    ++When they log into the self service interface, they will see an Assets menu
    ++that takes them to a page displaying assets assigned to them. In our example
    ++configuration, this is based on the Held by setting we set when we gave out the
    ++laptop. When they navigate to the asset, they will see the Actions menu with
    ++the same "Create linked ticket" action our staff uses. When they click on that,
    ++they'll end up on the simplified ticket create page for self service. When the
    ++ticket is created, the laptop will already be linked to it, saving our staff
    ++the work.
    ++
    ++=begin HTML
    ++
    ++<p><img
    ++src="http://static.bestpractical.com/images/assets/asset-ticket-create-selfservice.png"
    ++alt="Self Service Ticket for Asset Work" /></p>
    ++
    ++=end HTML
    ++
    ++=head1 Summary
    ++
    ++This tutorial is only a quick overview showing how the assets extension can
    ++help you track assets. There are many more features you'll find as you explore
    ++the assets interface, like stacking multiple assets on a single ticket, bulk
    ++update features similar to tickets, and the search interface. Have fun!
    ++
    ++=cut
    +
    +diff --git a/etc/RT_Config.pm.in b/etc/RT_Config.pm.in
    +--- a/etc/RT_Config.pm.in
    ++++ b/etc/RT_Config.pm.in
    +@@
    + 
    + =back
    + 
    ++=head1 Assets
    + 
    ++=over 4
    ++
    ++=item C<@AssetQueues>
    ++
    ++This should be a list of names of queues whose tickets should always
    ++display the "Assets" box.  This is useful for queues which deal
    ++primarily with assets, as it provides a ready box to link an asset to
    ++the ticket, even when the ticket has no related assets yet.
    ++
    ++=cut
    ++
    ++# Set(@AssetQueues, ());
    ++
    ++=item C<$DefaultCatalog>
    ++
    ++This provides the default catalog after a user initially logs in.
    ++However, the default catalog is "sticky," and so will remember the
    ++last-selected catalog thereafter.
    ++
    ++=cut
    ++
    ++# Set($DefaultCatalog, 'General assets');
    ++
    ++=item C<$AssetSearchFields>
    ++
    ++Specifies which fields of L<RT::Asset> to match against and how to match
    ++each field when performing a quick search on assets.  Valid match
    ++methods are LIKE, STARTSWITH, ENDSWITH, =, and !=.  Valid search fields
    ++are id, Name, Description, or custom fields, which are specified as
    ++"CF.1234" or "CF.Name"
    ++
    ++=cut
    ++
    ++Set($AssetSearchFields, {
    ++    id          => '=',
    ++    Name        => 'LIKE',
    ++    Description => 'LIKE',
    ++}) unless $AssetSearchFields;
    ++
    ++=item C<$AssetSearchFormat>
    ++
    ++The format that results of the asset search are displayed with.  This is
    ++either a string, which will be used for all catalogs, or a hash
    ++reference, keyed by catalog's name/id.  If a hashref and neither name or
    ++id is found therein, falls back to the key ''.
    ++
    ++If you wish to use the multiple catalog format, your configuration would look
    ++something like:
    ++
    ++    Set($AssetSearchFormat, {
    ++        'General assets' => q[Format String for the General Assets Catalog],
    ++        8                => q[Format String for Catalog 8],
    ++        ''               => q[Format String for any catalogs not listed explicitly],
    ++    });
    ++
    ++=cut
    ++
    ++# loc('Related tickets')
    ++Set($AssetSearchFormat, q[
    ++    '<a href="__WebHomePath__/Asset/Display.html?id=__id__">__Name__</a>/TITLE:Name',
    ++    Description,
    ++    '__Status__ (__Catalog__)/TITLE:Status',
    ++    Owner,
    ++    HeldBy,
    ++    Contacts,
    ++    '__ActiveTickets__ __InactiveTickets__/TITLE:Related tickets',
    ++]) unless $AssetSearchFormat;
    ++
    ++=item C<$AssetSummaryFormat>
    ++
    ++The information that is displayed on ticket display pages about assets
    ++related to the ticket.  This is displayed in a table beneath the asset
    ++name.
    ++
    ++=cut
    ++
    ++Set($AssetSummaryFormat, q[
    ++    '<a href="__WebHomePath__/Asset/Display.html?id=__id__">__Name__</a>/TITLE:Name',
    ++    Description,
    ++    '__Status__ (__Catalog__)/TITLE:Status',
    ++    Owner,
    ++    HeldBy,
    ++    Contacts,
    ++    '__ActiveTickets__ __InactiveTickets__/TITLE:Related tickets',
    ++]) unless $AssetSummaryFormat;
    ++
    ++=item C<$AssetSummaryRelatedTicketsFormat>
    ++
    ++The information that is displayed on ticket display pages about tickets
    ++related to assets related to the ticket.  This is displayed as a list of
    ++tickets underneath the asset properties.
    ++
    ++=cut
    ++
    ++Set($AssetSummaryRelatedTicketsFormat, q[
    ++    '<a href="__WebPath__/Ticket/Display.html?id=__id__">__id__</a>',
    ++    '(__OwnerName__)',
    ++    '<a href="__WebPath__/Ticket/Display.html?id=__id__">__Subject__</a>',
    ++    QueueName,
    ++    Status,
    ++]) unless $AssetSummaryRelatedTicketsFormat;
    ++
    ++=item C<%AdminSearchResultFormat>
    ++
    ++The C<Catalogs> key of this standard RT configuration option (see
    ++L<RT_Config/%AdminSearchResultFormat>) controls how catalogs are
    ++displayed in their list in the admin pages.
    ++
    ++=cut
    ++
    ++Set(%AdminSearchResultFormat,
    ++    Catalogs =>
    ++        q{'<a href="__WebPath__/Admin/Assets/Catalogs/Modify.html?id=__id__">__id__</a>/TITLE:#'}
    ++        .q{,'<a href="__WebPath__/Admin/Assets/Catalogs/Modify.html?id=__id__">__Name__</a>/TITLE:Name'}
    ++        .q{,__Description__,__Lifecycle__,__Disabled__},
    ++) unless $AdminSearchResultFormat{Catalogs};
    ++
    ++=item C<$AssetBasicCustomFieldsOnCreate>
    ++
    ++Specify a list of Asset custom fields to show in "Basics" widget on create.
    ++
    ++e.g.
    ++
    ++Set( $AssetBasicCustomFieldsOnCreate, [ 'foo', 'bar' ] );
    ++
    ++=cut
    ++
    ++# Set($AssetBasicCustomFieldsOnCreate, undef );
    ++
    ++=back
    + 
    + =head2 Message box properties
    + 
    +@@
    + 
    + =back
    + 
    +-
    +-
    + =head1 Cryptography
    + 
    + A complete description of RT's cryptography capabilities can be found in
    +@@
    +             'deleted -> open'  => { label  => 'Undelete',                    }, # loc{label}
    +         ],
    +     },
    ++    assets => {
    ++        type     => "asset",
    ++        initial  => [ 
    ++            'new' # loc
    ++        ],
    ++        active   => [ 
    ++            'allocated', # loc
    ++            'in-use' # loc
    ++        ],
    ++        inactive => [ 
    ++            'recycled', # loc
    ++            'stolen', # loc
    ++            'deleted' # loc
    ++        ],
    ++
    ++        defaults => {
    ++            on_create => 'new',
    ++        },
    ++
    ++        transitions => {
    ++            ''        => [qw(new allocated in-use)],
    ++            new       => [qw(allocated in-use stolen deleted)],
    ++            allocated => [qw(in-use recycled stolen deleted)],
    ++            "in-use"  => [qw(allocated recycled stolen deleted)],
    ++            recycled  => [qw(allocated)],
    ++            stolen    => [qw(allocated)],
    ++            deleted   => [qw(allocated)],
    ++        },
    ++        rights => {
    ++            '* -> *'        => 'ModifyAsset',
    ++        },
    ++        actions => {
    ++            '* -> allocated' => { 
    ++                label => "Allocate" # loc
    ++            },
    ++            '* -> in-use'    => { 
    ++                label => "Now in-use" # loc
    ++            },
    ++            '* -> recycled'  => { 
    ++                label => "Recycle" # loc
    ++            },
    ++            '* -> stolen'    => { 
    ++                label => "Report stolen" # loc
    ++            },
    ++        },
    ++    },
    + );
    + 
    + 
    +
    +diff --git a/etc/acl.Pg b/etc/acl.Pg
    +--- a/etc/acl.Pg
    ++++ b/etc/acl.Pg
    +@@
    +         }
    +     }
    +     return (@acls);
    ++
    ++{ # START assets ACL
    ++    my @tables = qw (
    ++        assets_id_seq
    ++        Assets
    ++        catalogs_id_seq
    ++        Catalogs
    ++    );
    ++
    ++    my $db_user = RT->Config->Get('DatabaseUser');
    ++
    ++    my $sequence_right
    ++        = ( $dbh->{pg_server_version} >= 80200 )
    ++        ? "USAGE, SELECT, UPDATE"
    ++        : "SELECT, UPDATE";
    ++
    ++    foreach my $table (@tables) {
    ++        # Tables are upper-case, sequences are lowercase in @tables
    ++        if ( $table =~ /^[a-z]/ ) {
    ++            push @acls, "GRANT $sequence_right ON $table TO \"$db_user\";"
    ++        }
    ++        else {
    ++            push @acls, "GRANT SELECT, INSERT, UPDATE, DELETE ON $table TO \"$db_user\";"
    ++        }
    ++    }
    ++
    ++} # END Assets ACL
    ++
    ++    return (@acls);
    + }
    + 
    + 1;
    +
    +diff --git a/etc/initialdata b/etc/initialdata
    +--- a/etc/initialdata
    ++++ b/etc/initialdata
    +@@
    +         },
    +     },
    + );
    ++
    ++require RT::Asset;
    ++# Create global role groups
    ++push @Final, sub {
    ++    foreach my $type (RT::Asset->Roles) {
    ++        next if $type eq "Owner";   # There's a core global role group for Owner
    ++
    ++        my $group = RT::Group->new( RT->SystemUser );
    ++        my ($ok, $msg) = $group->CreateRoleGroup(
    ++            Object              => RT->System,
    ++            Name                => $type,
    ++            InsideTransaction   => 0,
    ++        );
    ++        RT->Logger->error("Couldn't create global asset role group '$type': $msg")
    ++            unless $ok;
    ++    }
    ++};
    ++
    ++# Create default catalog
    ++push @Final, sub {
    ++    my $catalog = RT::Catalog->new( RT->SystemUser );
    ++    my ($ok, $msg) = $catalog->Create(
    ++        Name        => "General assets",
    ++        Description => "The default catalog",
    ++    );
    ++    RT->Logger->error("Couldn't create default catalog 'General assets': $msg")
    ++        unless $ok;
    ++};
    ++
    ++1;
    +
    +diff --git a/etc/schema.Oracle b/etc/schema.Oracle
    +--- a/etc/schema.Oracle
    ++++ b/etc/schema.Oracle
    +@@
    + LastUpdatedBy NUMBER(11,0) DEFAULT 0 NOT NULL,
    + LastUpdated DATE
    + );
    ++CREATE SEQUENCE Assets_seq;
    ++CREATE TABLE Assets (
    ++    id              NUMBER(11,0)    CONSTRAINT Assets_key PRIMARY KEY,
    ++    Name            varchar2(255)   DEFAULT '',
    ++    Catalog         NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    Status          varchar2(64)    DEFAULT '',
    ++    Description     varchar2(255)   DEFAULT '',
    ++    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    Created         DATE,
    ++    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    LastUpdated     DATE
    ++);
    ++
    ++CREATE INDEX AssetsName ON Assets (LOWER(Name));
    ++CREATE INDEX AssetsStatus ON Assets (Status);
    ++CREATE INDEX AssetsCatalog ON Assets (Catalog);
    ++
    ++CREATE SEQUENCE Catalogs_seq;
    ++CREATE TABLE Catalogs (
    ++    id              NUMBER(11,0)    CONSTRAINT Catalogs_key PRIMARY KEY,
    ++    Name            varchar2(255)   DEFAULT '',
    ++    Lifecycle       varchar2(32)    DEFAULT 'assets',
    ++    Description     varchar2(255)   DEFAULT '',
    ++    Disabled        NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    Created         DATE,
    ++    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    LastUpdated     DATE
    ++);
    ++
    ++CREATE INDEX CatalogsName ON Catalogs (LOWER(Name));
    ++CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
    +
    +diff --git a/etc/schema.Pg b/etc/schema.Pg
    +--- a/etc/schema.Pg
    ++++ b/etc/schema.Pg
    +@@
    + PRIMARY KEY (id)
    + );
    + 
    ++CREATE SEQUENCE assets_id_seq;
    ++CREATE TABLE Assets (
    ++    id                integer                  DEFAULT nextval('assets_id_seq'),
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Catalog           integer         NOT NULL DEFAULT 0,
    ++    Status            varchar(64)     NOT NULL DEFAULT '',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Creator           integer         NOT NULL DEFAULT 0,
    ++    Created           timestamp                DEFAULT NULL,
    ++    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
    ++    LastUpdated       timestamp                DEFAULT NULL,
    ++    PRIMARY KEY (id)
    ++);
    ++
    ++CREATE INDEX AssetsName ON Assets (LOWER(Name));
    ++CREATE INDEX AssetsStatus ON Assets (Status);
    ++CREATE INDEX AssetsCatalog ON Assets (Catalog);
    ++
    ++CREATE SEQUENCE catalogs_id_seq;
    ++CREATE TABLE Catalogs (
    ++    id                integer                  DEFAULT nextval('catalogs_id_seq'),
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Disabled          integer         NOT NULL DEFAULT 0,
    ++    Creator           integer         NOT NULL DEFAULT 0,
    ++    Created           timestamp                DEFAULT NULL,
    ++    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
    ++    LastUpdated       timestamp                DEFAULT NULL,
    ++    PRIMARY KEY (id)
    ++);
    ++
    ++CREATE INDEX CatalogsName ON Catalogs (LOWER(Name));
    ++CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
    +
    +diff --git a/etc/schema.SQLite b/etc/schema.SQLite
    +--- a/etc/schema.SQLite
    ++++ b/etc/schema.SQLite
    +@@
    + LastUpdatedBy integer NOT NULL DEFAULT 0,
    + LastUpdated TIMESTAMP NULL
    + );
    ++CREATE TABLE Assets (
    ++    id                INTEGER PRIMARY KEY,
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Catalog           int(11)         NOT NULL DEFAULT 0,
    ++    Status            varchar(64)     NOT NULL DEFAULT '',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Creator           int(11)         NOT NULL DEFAULT 0,
    ++    Created           timestamp                DEFAULT NULL,
    ++    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
    ++    LastUpdated       timestamp                DEFAULT NULL
    ++);
    ++
    ++CREATE INDEX AssetsName on Assets (Name);
    ++CREATE INDEX AssetsStatus ON Assets (Status);
    ++CREATE INDEX AssetsCatalog ON Assets (Catalog);
    ++
    ++CREATE TABLE Catalogs (
    ++    id                INTEGER PRIMARY KEY,
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Disabled          int2            NOT NULL DEFAULT 0,
    ++    Creator           int(11)         NOT NULL DEFAULT 0,
    ++    Created           timestamp                DEFAULT NULL,
    ++    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
    ++    LastUpdated       timestamp                DEFAULT NULL
    ++);
    ++
    ++CREATE INDEX CatalogsName on Catalogs (Name);
    ++CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
    +
    +diff --git a/etc/schema.mysql b/etc/schema.mysql
    +--- a/etc/schema.mysql
    ++++ b/etc/schema.mysql
    +@@
    +   LastUpdated datetime default NULL,
    +   PRIMARY KEY  (id)
    + ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    ++CREATE TABLE Assets (
    ++    id                int(11)         NOT NULL AUTO_INCREMENT,
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Catalog           int(11)         NOT NULL DEFAULT 0,
    ++    Status            varchar(64)     NOT NULL DEFAULT '',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Creator           int(11)         NOT NULL DEFAULT 0,
    ++    Created           datetime                 DEFAULT NULL,
    ++    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
    ++    LastUpdated       datetime                 DEFAULT NULL,
    ++    PRIMARY KEY (id)
    ++) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    ++
    ++CREATE INDEX AssetsName ON Assets (Name);
    ++CREATE INDEX AssetsStatus ON Assets (Status);
    ++CREATE INDEX AssetsCatalog ON Assets (Catalog);
    ++
    ++CREATE TABLE Catalogs (
    ++    id                int(11)         NOT NULL AUTO_INCREMENT,
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Disabled          int2            NOT NULL DEFAULT 0,
    ++    Creator           int(11)         NOT NULL DEFAULT 0,
    ++    Created           datetime                 DEFAULT NULL,
    ++    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
    ++    LastUpdated       datetime                 DEFAULT NULL,
    ++    PRIMARY KEY (id)
    ++) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    ++
    ++CREATE INDEX CatalogsName ON Catalogs (Name);
    ++CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
    +
    +diff --git a/etc/upgrade/4.3.10/content b/etc/upgrade/4.3.10/content
    +new file mode 100644
    +--- /dev/null
    ++++ b/etc/upgrade/4.3.10/content
    +@@
    ++
    ++require RT::Asset;
    ++
    ++# Create global role groups
    ++push @Final, sub {
    ++    foreach my $type (RT::Asset->Roles) {
    ++        next if $type eq "Owner";   # There's a core global role group for Owner
    ++
    ++        my $group = RT::Group->new( RT->SystemUser );
    ++        my ($ok, $msg) = $group->CreateRoleGroup(
    ++            Object              => RT->System,
    ++            Name                => $type,
    ++            InsideTransaction   => 0,
    ++        );
    ++        RT->Logger->error("Couldn't create global asset role group '$type': $msg")
    ++            unless $ok;
    ++    }
    ++};
    ++
    ++# Create default catalog
    ++push @Final, sub {
    ++    my $catalog = RT::Catalog->new( RT->SystemUser );
    ++    my ($ok, $msg) = $catalog->Create(
    ++        Name        => "General assets",
    ++        Description => "The default catalog",
    ++    );
    ++    RT->Logger->error("Couldn't create default catalog 'General assets': $msg")
    ++        unless $ok;
    ++};
    ++
    ++1;
    +
    +diff --git a/etc/upgrade/4.3.10/schema.Oracle b/etc/upgrade/4.3.10/schema.Oracle
    +new file mode 100644
    +--- /dev/null
    ++++ b/etc/upgrade/4.3.10/schema.Oracle
    +@@
    ++
    ++CREATE SEQUENCE Assets_seq;
    ++CREATE TABLE Assets (
    ++    id              NUMBER(11,0)    CONSTRAINT Assets_key PRIMARY KEY,
    ++    Name            varchar2(255)   DEFAULT '',
    ++    Catalog         NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    Status          varchar2(64)    DEFAULT '',
    ++    Description     varchar2(255)   DEFAULT '',
    ++    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    Created         DATE,
    ++    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    LastUpdated     DATE
    ++);
    ++
    ++CREATE INDEX AssetsName ON Assets (LOWER(Name));
    ++CREATE INDEX AssetsStatus ON Assets (Status);
    ++CREATE INDEX AssetsCatalog ON Assets (Catalog);
    ++
    ++CREATE SEQUENCE Catalogs_seq;
    ++CREATE TABLE Catalogs (
    ++    id              NUMBER(11,0)    CONSTRAINT Catalogs_key PRIMARY KEY,
    ++    Name            varchar2(255)   DEFAULT '',
    ++    Lifecycle       varchar2(32)    DEFAULT 'assets',
    ++    Description     varchar2(255)   DEFAULT '',
    ++    Disabled        NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    Creator         NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    Created         DATE,
    ++    LastUpdatedBy   NUMBER(11,0)    DEFAULT 0 NOT NULL,
    ++    LastUpdated     DATE
    ++);
    ++
    ++CREATE INDEX CatalogsName ON Catalogs (LOWER(Name));
    ++CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
    +
    +diff --git a/etc/upgrade/4.3.10/schema.Pg b/etc/upgrade/4.3.10/schema.Pg
    +new file mode 100644
    +--- /dev/null
    ++++ b/etc/upgrade/4.3.10/schema.Pg
    +@@
    ++
    ++CREATE SEQUENCE assets_id_seq;
    ++CREATE TABLE Assets (
    ++    id                integer                  DEFAULT nextval('assets_id_seq'),
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Catalog           integer         NOT NULL DEFAULT 0,
    ++    Status            varchar(64)     NOT NULL DEFAULT '',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Creator           integer         NOT NULL DEFAULT 0,
    ++    Created           timestamp                DEFAULT NULL,
    ++    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
    ++    LastUpdated       timestamp                DEFAULT NULL,
    ++    PRIMARY KEY (id)
    ++);
    ++
    ++CREATE INDEX AssetsName ON Assets (LOWER(Name));
    ++CREATE INDEX AssetsStatus ON Assets (Status);
    ++CREATE INDEX AssetsCatalog ON Assets (Catalog);
    ++
    ++CREATE SEQUENCE catalogs_id_seq;
    ++CREATE TABLE Catalogs (
    ++    id                integer                  DEFAULT nextval('catalogs_id_seq'),
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Disabled          integer         NOT NULL DEFAULT 0,
    ++    Creator           integer         NOT NULL DEFAULT 0,
    ++    Created           timestamp                DEFAULT NULL,
    ++    LastUpdatedBy     integer         NOT NULL DEFAULT 0,
    ++    LastUpdated       timestamp                DEFAULT NULL,
    ++    PRIMARY KEY (id)
    ++);
    ++
    ++CREATE INDEX CatalogsName ON Catalogs (LOWER(Name));
    ++CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
    +
    +diff --git a/etc/upgrade/4.3.10/schema.SQLite b/etc/upgrade/4.3.10/schema.SQLite
    +new file mode 100644
    +--- /dev/null
    ++++ b/etc/upgrade/4.3.10/schema.SQLite
    +@@
    ++
    ++CREATE TABLE Assets (
    ++    id                INTEGER PRIMARY KEY,
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Catalog           int(11)         NOT NULL DEFAULT 0,
    ++    Status            varchar(64)     NOT NULL DEFAULT '',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Creator           int(11)         NOT NULL DEFAULT 0,
    ++    Created           timestamp                DEFAULT NULL,
    ++    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
    ++    LastUpdated       timestamp                DEFAULT NULL
    ++);
    ++
    ++CREATE INDEX AssetsName on Assets (Name);
    ++CREATE INDEX AssetsStatus ON Assets (Status);
    ++CREATE INDEX AssetsCatalog ON Assets (Catalog);
    ++
    ++CREATE TABLE Catalogs (
    ++    id                INTEGER PRIMARY KEY,
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Disabled          int2            NOT NULL DEFAULT 0,
    ++    Creator           int(11)         NOT NULL DEFAULT 0,
    ++    Created           timestamp                DEFAULT NULL,
    ++    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
    ++    LastUpdated       timestamp                DEFAULT NULL
    ++);
    ++
    ++CREATE INDEX CatalogsName on Catalogs (Name);
    ++CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
    +
    +diff --git a/etc/upgrade/4.3.10/schema.mysql b/etc/upgrade/4.3.10/schema.mysql
    +new file mode 100644
    +--- /dev/null
    ++++ b/etc/upgrade/4.3.10/schema.mysql
    +@@
    ++
    ++CREATE TABLE Assets (
    ++    id                int(11)         NOT NULL AUTO_INCREMENT,
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Catalog           int(11)         NOT NULL DEFAULT 0,
    ++    Status            varchar(64)     NOT NULL DEFAULT '',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Creator           int(11)         NOT NULL DEFAULT 0,
    ++    Created           datetime                 DEFAULT NULL,
    ++    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
    ++    LastUpdated       datetime                 DEFAULT NULL,
    ++    PRIMARY KEY (id)
    ++) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    ++
    ++CREATE INDEX AssetsName ON Assets (Name);
    ++CREATE INDEX AssetsStatus ON Assets (Status);
    ++CREATE INDEX AssetsCatalog ON Assets (Catalog);
    ++
    ++CREATE TABLE Catalogs (
    ++    id                int(11)         NOT NULL AUTO_INCREMENT,
    ++    Name              varchar(255)    NOT NULL DEFAULT '',
    ++    Lifecycle         varchar(32)     NOT NULL DEFAULT 'assets',
    ++    Description       varchar(255)    NOT NULL DEFAULT '',
    ++    Disabled          int2            NOT NULL DEFAULT 0,
    ++    Creator           int(11)         NOT NULL DEFAULT 0,
    ++    Created           datetime                 DEFAULT NULL,
    ++    LastUpdatedBy     int(11)         NOT NULL DEFAULT 0,
    ++    LastUpdated       datetime                 DEFAULT NULL,
    ++    PRIMARY KEY (id)
    ++) ENGINE=InnoDB DEFAULT CHARSET=utf8;
    ++
    ++CREATE INDEX CatalogsName ON Catalogs (Name);
    ++CREATE INDEX CatalogsDisabled ON Catalogs (Disabled);
    +
    +diff --git a/etc/upgrade/upgrade-assets.in b/etc/upgrade/upgrade-assets.in
    +new file mode 100644
    +--- /dev/null
    ++++ b/etc/upgrade/upgrade-assets.in
    +@@
    ++#!@PERL@
    ++# BEGIN BPS TAGGED BLOCK {{{
    ++#
    ++# COPYRIGHT:
    ++#
    ++# This software is Copyright (c) 1996-2015 Best Practical Solutions, LLC
    ++#                                          <sales at bestpractical.com>
    ++#
    ++# (Except where explicitly superseded by other copyright notices)
    ++#
    ++#
    ++# LICENSE:
    ++#
    ++# This work is made available to you under the terms of Version 2 of
    ++# the GNU General Public License. A copy of that license should have
    ++# been provided with this software, but in any event can be snarfed
    ++# from www.gnu.org.
    ++#
    ++# This work is distributed in the hope that it will be useful, but
    ++# WITHOUT ANY WARRANTY; without even the implied warranty of
    ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    ++# General Public License for more details.
    ++#
    ++# You should have received a copy of the GNU General Public License
    ++# along with this program; if not, write to the Free Software
    ++# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
    ++# 02110-1301 or visit their web page on the internet at
    ++# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
    ++#
    ++#
    ++# CONTRIBUTION SUBMISSION POLICY:
    ++#
    ++# (The following paragraph is not intended to limit the rights granted
    ++# to you to modify and distribute this software under the terms of
    ++# the GNU General Public License and is only of importance to you if
    ++# you choose to contribute your changes and enhancements to the
    ++# community by submitting them to Best Practical Solutions, LLC.)
    ++#
    ++# By intentionally submitting any modifications, corrections or
    ++# derivatives to this work, or any other work intended for use with
    ++# Request Tracker, to Best Practical Solutions, LLC, you confirm that
    ++# you are the copyright holder for those contributions and you grant
    ++# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
    ++# royalty-free, perpetual, license to use, copy, create derivative
    ++# works based on those contributions, and sublicense and distribute
    ++# those contributions and any derivatives thereof.
    ++#
    ++# END BPS TAGGED BLOCK }}}
    ++use 5.10.1;
    ++use strict;
    ++use warnings;
    ++
    ++use lib "@LOCAL_LIB_PATH@";
    ++use lib "@RT_LIB_PATH@";
    ++
    ++use RT::Interface::CLI qw(Init);
    ++Init();
    ++
    ++my $db_name = RT->Config->Get('DatabaseName');
    ++my $db_type = RT->Config->Get('DatabaseType');
    ++
    ++my $dbh = $RT::Handle->dbh;
    ++
    ++my $found_assets_tables;
    ++foreach my $name ( $RT::Handle->_TableNames ) {
    ++    next unless $name =~ /^RTx_/i;
    ++    $found_assets_tables->{lc $name}++;
    ++}
    ++
    ++unless ( $found_assets_tables->{rtxassets} && $found_assets_tables->{rtxcatalogs} ) {
    ++    warn "Could not find RT::Extension::Assets data to migrate";
    ++    exit;
    ++}
    ++
    ++{ # port over Catalogs
    ++    my @columns = qw(id Name Lifecycle Description Disabled Creator Created LastUpdatedBy LastUpdated);
    ++    copy_tables('RTxCatalogs','Catalogs',\@columns);
    ++
    ++}
    ++
    ++
    ++{ # port over Assets
    ++    my @columns = qw(id Name Catalog Status Description Creator Created LastUpdatedBy LastUpdated);
    ++    copy_tables('RTxAssets','Assets',\@columns);
    ++}
    ++
    ++sub copy_tables {
    ++    my ($source, $dest, $columns) = @_;
    ++    my $column_list = join(', ',@$columns);
    ++    my $sql;
    ++    # SQLite: http://www.sqlite.org/lang_insert.html
    ++    if ( $db_type eq 'mysql' || $db_type eq 'SQLite' ) {
    ++        $sql = "insert into $dest ($column_list) select $column_list from $source";
    ++    }
    ++    # Oracle: http://www.adp-gmbh.ch/ora/sql/insert/select_and_subquery.html
    ++    elsif ( $db_type eq 'Pg' || $db_type eq 'Oracle' ) {
    ++        $sql = "insert into $dest ($column_list) (select $column_list from $source)";
    ++    }
    ++    $RT::Logger->debug($sql);
    ++    $dbh->do($sql);
    ++}
    +
    +diff --git a/lib/RT.pm b/lib/RT.pm
    +--- a/lib/RT.pm
    ++++ b/lib/RT.pm
    +@@
    +     require RT::Topics;
    +     require RT::Link;
    +     require RT::Links;
    ++    require RT::Catalog;
    ++    require RT::Catalogs;
    ++    require RT::Asset;
    ++    require RT::Assets;
    + 
    +     _BuildTableAttributes();
    + 
    +
    +diff --git a/lib/RT/Asset.pm b/lib/RT/Asset.pm
    +new file mode 100644
    +--- /dev/null
    ++++ b/lib/RT/Asset.pm
    +@@
    ++# BEGIN BPS TAGGED BLOCK {{{
    ++#
    ++# COPYRIGHT:
    ++#
    ++# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
    ++#                                          <sales at bestpractical.com>
    ++#
    ++# (Except where explicitly superseded by other copyright notices)
    ++#
    ++#
    ++# LICENSE:
    ++#
    ++# This work is made available to you under the terms of Version 2 of
    ++# the GNU General Public License. A copy of that license should have
    ++# been provided with this software, but in any event can be snarfed
    ++# from www.gnu.org.
    ++#
    ++# This work is distributed in the hope that it will be useful, but
    ++# WITHOUT ANY WARRANTY; without even the implied warranty of
    ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    ++# General Public License for more details.
    ++#
    ++# You should have received a copy of the GNU General Public License
    ++# along with this program; if not, write to the Free Software
    ++# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
    ++# 02110-1301 or visit their web page on the internet at
    ++# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
    ++#
    ++#
    ++# CONTRIBUTION SUBMISSION POLICY:
    ++#
    ++# (The following paragraph is not intended to limit the rights granted
    ++# to you to modify and distribute this software under the terms of
    ++# the GNU General Public License and is only of importance to you if
    ++# you choose to contribute your changes and enhancements to the
    ++# community by submitting them to Best Practical Solutions, LLC.)
    ++#
    ++# By intentionally submitting any modifications, corrections or
    ++# derivatives to this work, or any other work intended for use with
    ++# Request Tracker, to Best Practical Solutions, LLC, you confirm that
    ++# you are the copyright holder for those contributions and you grant
    ++# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
    ++# royalty-free, perpetual, license to use, copy, create derivative
    ++# works based on those contributions, and sublicense and distribute
    ++# those contributions and any derivatives thereof.
    ++#
    ++# END BPS TAGGED BLOCK }}}
    ++
    ++use strict;
    ++use warnings;
    ++use 5.10.1;
    ++
    ++package RT::Asset;
    ++use base 'RT::Record';
    ++
    ++use Role::Basic "with";
    ++with "RT::Record::Role::Status",
    ++     "RT::Record::Role::Links",
    ++     "RT::Record::Role::Roles" => {
    ++         -rename => {
    ++             # We provide ACL'd wraps of these.
    ++             AddRoleMember    => "_AddRoleMember",
    ++             DeleteRoleMember => "_DeleteRoleMember",
    ++             RoleGroup        => "_RoleGroup",
    ++         },
    ++     };
    ++
    ++require RT::Catalog;
    ++require RT::CustomField;
    ++require RT::URI::asset;
    ++
    ++=head1 NAME
    ++
    ++RT::Asset - Represents a single asset record
    ++
    ++=cut
    ++
    ++sub LifecycleColumn { "Catalog" }
    ++
    ++# Assets are primarily built on custom fields
    ++RT::CustomField->RegisterLookupType( CustomFieldLookupType() => 'Assets' );
    ++RT::CustomField->RegisterBuiltInGroupings(
    ++    'RT::Asset' => [qw( Basics Dates People Links )]
    ++);
    ++
    ++# loc('Owner')
    ++# loc('HeldBy')
    ++# loc('Contact')
    ++for my $role ('Owner', 'HeldBy', 'Contact') {
    ++    state $i = 1;
    ++    RT::Asset->RegisterRole(
    ++        Name            => $role,
    ++        EquivClasses    => ["RT::Catalog"],
    ++        SortOrder       => $i++,
    ++        ( $role eq "Owner"
    ++            ? ( Single         => 1,
    ++                ACLOnlyInEquiv => 1, )
    ++            : () ),
    ++    );
    ++}
    ++
    ++=head1 DESCRIPTION
    ++
    ++An Asset is a small record object upon which zero to many custom fields are
    ++applied.  The core fields are:
    ++
    ++=over 4
    ++
    ++=item id
    ++
    ++=item Name
    ++
    ++Limited to 255 characters.
    ++
    ++=item Description
    ++
    ++Limited to 255 characters.
    ++
    ++=item Catalog
    ++
    ++=item Status
    ++
    ++=item Creator
    ++
    ++=item Created
    ++
    ++=item LastUpdatedBy
    ++
    ++=item LastUpdated
    ++
    ++=back
    ++
    ++All of these are readable through methods of the same name and mutable through
    ++methods of the same name with C<Set> prefixed.  The last four are automatically
    ++managed.
    ++
    ++=head1 METHODS
    ++
    ++=head2 Load ID or NAME
    ++
    ++Loads the specified Asset into the current object.
    ++
    ++=cut
    ++
    ++sub Load {
    ++    my $self = shift;
    ++    my $id   = shift;
    ++    return unless $id;
    ++
    ++    if ( $id =~ /\D/ ) {
    ++        return $self->LoadByCols( Name => $id );
    ++    }
    ++    else {
    ++        return $self->SUPER::Load($id);
    ++    }
    ++}
    ++
    ++=head2 Create PARAMHASH
    ++
    ++Create takes a hash of values and creates a row in the database.  Available keys are:
    ++
    ++=over 4
    ++
    ++=item Name
    ++
    ++=item Description
    ++
    ++=item Catalog
    ++
    ++Name or numeric ID
    ++
    ++=item CustomField-<ID>
    ++
    ++Sets the value for this asset of the custom field specified by C<< <ID> >>.
    ++
    ++C<< <ID> >> should be a numeric ID, but may also be a Name if and only if your
    ++custom fields have unique names.  Without unique names, the behaviour is
    ++undefined.
    ++
    ++=item Status
    ++
    ++=item Owner, HeldBy, Contact
    ++
    ++A single principal ID or array ref of principal IDs to add as members of the
    ++respective role groups for the new asset.
    ++
    ++User Names and EmailAddresses may also be used, but Groups must be referenced
    ++by ID.
    ++
    ++=item RefersTo, ReferredToBy, DependsOn, DependedOnBy, Parents, Children, and aliases
    ++
    ++Any of these link types accept either a single value or arrayref of values
    ++parseable by L<RT::URI>.
    ++
    ++=back
    ++
    ++Returns a tuple of (status, msg) on failure and (id, msg, non-fatal errors) on
    ++success, where the third value is an array reference of errors that occurred
    ++but didn't prevent creation.
    ++
    ++=cut
    ++
    ++sub Create {
    ++    my $self = shift;
    ++    my %args = (
    ++        Name            => '',
    ++        Description     => '',
    ++        Catalog         => undef,
    ++
    ++        Owner           => undef,
    ++        HeldBy          => undef,
    ++        Contact         => undef,
    ++
    ++        Status          => undef,
    ++        @_
    ++    );
    ++    my @non_fatal_errors;
    ++
    ++    return (0, $self->loc("Invalid Catalog"))
    ++        unless $self->ValidateCatalog( $args{'Catalog'} );
    ++
    ++    my $catalog = RT::Catalog->new( $self->CurrentUser );
    ++    $catalog->Load($args{'Catalog'});
    ++
    ++    $args{'Catalog'} = $catalog->id;
    ++
    ++    return (0, $self->loc("Permission Denied"))
    ++        unless $catalog->CurrentUserHasRight('CreateAsset');
    ++
    ++    return (0, $self->loc('Invalid Name (names may not be all digits)'))
    ++        unless $self->ValidateName( $args{'Name'} );
    ++
    ++    # XXX TODO: This status/lifecycle pattern is duplicated in RT::Ticket and
    ++    # should be refactored into a role helper.
    ++    my $cycle = $catalog->LifecycleObj;
    ++    unless ( defined $args{'Status'} && length $args{'Status'} ) {
    ++        $args{'Status'} = $cycle->DefaultOnCreate;
    ++    }
    ++
    ++    $args{'Status'} = lc $args{'Status'};
    ++    unless ( $cycle->IsValid( $args{'Status'} ) ) {
    ++        return ( 0,
    ++            $self->loc("Status '[_1]' isn't a valid status for assets.",
    ++                $self->loc($args{'Status'}))
    ++        );
    ++    }
    ++
    ++    unless ( $cycle->IsTransition( '' => $args{'Status'} ) ) {
    ++        return ( 0,
    ++            $self->loc("New assets cannot have status '[_1]'.",
    ++                $self->loc($args{'Status'}))
    ++        );
    ++    }
    ++
    ++    my $roles = {};
    ++    my @errors = $self->_ResolveRoles( $roles, %args );
    ++    return (0, @errors) if @errors;
    ++
    ++    RT->DatabaseHandle->BeginTransaction();
    ++
    ++    my ( $id, $msg ) = $self->SUPER::Create(
    ++        map { $_ => $args{$_} } grep {exists $args{$_}}
    ++            qw(id Name Description Catalog Status),
    ++    );
    ++    unless ($id) {
    ++        RT->DatabaseHandle->Rollback();
    ++        return (0, $self->loc("Asset create failed: [_1]", $msg));
    ++    }
    ++
    ++    # Let users who just created an asset see it until the end of this method.
    ++    $self->{_object_is_readable} = 1;
    ++
    ++    # Create role groups
    ++    unless ($self->_CreateRoleGroups()) {
    ++        RT->Logger->error("Couldn't create role groups for asset ". $self->id);
    ++        RT->DatabaseHandle->Rollback();
    ++        return (0, $self->loc("Couldn't create role groups for asset"));
    ++    }
    ++
    ++    # Figure out users for roles
    ++    push @non_fatal_errors, $self->_AddRolesOnCreate( $roles, map { $_ => sub {1} } $self->Roles );
    ++
    ++    # Add CFs
    ++    foreach my $key (keys %args) {
    ++        next unless $key =~ /^CustomField-(.+)$/i;
    ++        my $cf   = $1;
    ++        my @vals = ref $args{$key} eq 'ARRAY' ? @{ $args{$key} } : $args{$key};
    ++        foreach my $value (@vals) {
    ++            next unless defined $value;
    ++
    ++            my ( $cfid, $cfmsg ) = $self->AddCustomFieldValue(
    ++                (ref($value) eq 'HASH'
    ++                    ? %$value
    ++                    : (Value => $value)),
    ++                Field             => $cf,
    ++                RecordTransaction => 0
    ++            );
    ++            unless ($cfid) {
    ++                RT->DatabaseHandle->Rollback();
    ++                return (0, $self->loc("Couldn't add custom field value on create: [_1]", $cfmsg));
    ++            }
    ++        }
    ++    }
    ++
    ++    # Create transaction
    ++    my ( $txn_id, $txn_msg, $txn ) = $self->_NewTransaction( Type => 'Create' );
    ++    unless ($txn_id) {
    ++        RT->DatabaseHandle->Rollback();
    ++        return (0, $self->loc( 'Asset Create txn failed: [_1]', $txn_msg ));
    ++    }
    ++
    ++    # Add links
    ++    push @non_fatal_errors, $self->_AddLinksOnCreate(\%args);
    ++
    ++    RT->DatabaseHandle->Commit();
    ++
    ++    # Let normal ACLs take over.
    ++    delete $self->{_object_is_readable};
    ++
    ++    return ($id, $self->loc('Asset #[_1] created: [_2]', $self->id, $args{'Name'}), \@non_fatal_errors);
    ++}
    ++
    ++=head2 ValidateName NAME
    ++
    ++Requires that Names contain at least one non-digit.  Empty names are OK.
    ++
    ++=cut
    ++
    ++sub ValidateName {
    ++    my $self = shift;
    ++    my $name = shift;
    ++    return 1 unless defined $name and length $name;
    ++    return 0 unless $name =~ /\D/;
    ++    return 1;
    ++}
    ++
    ++=head2 ValidateCatalog
    ++
    ++Takes a catalog name or ID.  Returns true if the catalog exists and is not
    ++disabled, otherwise false.
    ++
    ++=cut
    ++
    ++sub ValidateCatalog {
    ++    my $self    = shift;
    ++    my $name    = shift;
    ++    my $catalog = RT::Catalog->new( $self->CurrentUser );
    ++    $catalog->Load($name);
    ++    return 1 if $catalog->id and not $catalog->Disabled;
    ++    return 0;
    ++}
    ++
    ++=head2 Delete
    ++
    ++Assets may not be deleted.  Always returns failure.
    ++
    ++You should disable the asset instead with C<< $asset->SetStatus('deleted') >>.
    ++
    ++=cut
    ++
    ++sub Delete {
    ++    my $self = shift;
    ++    return (0, $self->loc("Assets may not be deleted"));
    ++}
    ++
    ++=head2 CurrentUserHasRight RIGHTNAME
    ++
    ++Returns true if the current user has the right for this asset, or globally if
    ++this is called on an unloaded object.
    ++
    ++=cut
    ++
    ++sub CurrentUserHasRight {
    ++    my $self  = shift;
    ++    my $right = shift;
    ++
    ++    return (
    ++        $self->CurrentUser->HasRight(
    ++            Right        => $right,
    ++            Object       => ($self->id ? $self : RT->System),
    ++        )
    ++    );
    ++}
    ++
    ++=head2 CurrentUserCanSee
    ++
    ++Returns true if the current user can see the asset, either because they just
    ++created it or they have the I<ShowAsset> right.
    ++
    ++=cut
    ++
    ++sub CurrentUserCanSee {
    ++    my $self = shift;
    ++    return $self->{_object_is_readable} || $self->CurrentUserHasRight('ShowAsset');
    ++}
    ++
    ++=head2 URI
    ++
    ++Returns this asset's URI
    ++
    ++=cut
    ++
    ++sub URI {
    ++    my $self = shift;
    ++    my $uri = RT::URI::asset->new($self->CurrentUser);
    ++    return $uri->URIForObject($self);
    ++}
    ++
    ++=head2 CatalogObj
    ++
    ++Returns the L<RT::Catalog> object for this asset's catalog.
    ++
    ++=cut
    ++
    ++sub CatalogObj {
    ++    my $self = shift;
    ++    my $catalog = RT::Catalog->new($self->CurrentUser);
    ++    $catalog->Load( $self->__Value("Catalog") );
    ++    return $catalog;
    ++}
    ++
    ++=head2 SetCatalog
    ++
    ++Validates the supplied catalog and updates the column if valid.  Transitions
    ++Status if necessary.  Returns a (status, message) tuple.
    ++
    ++=cut
    ++
    ++sub SetCatalog {
    ++    my $self  = shift;
    ++    my $value = shift;
    ++
    ++    return (0, $self->loc("Permission Denied"))
    ++        unless $self->CurrentUserHasRight("ModifyAsset");
    ++
    ++    my ($ok, $msg, $status) = $self->_SetLifecycleColumn(
    ++        Value           => $value,
    ++        RequireRight    => "CreateAsset"
    ++    );
    ++    return ($ok, $msg);
    ++}
    ++
    ++
    ++=head2 Owner
    ++
    ++Returns an L<RT::User> object for this asset's I<Owner> role group.  On error,
    ++returns undef.
    ++
    ++=head2 HeldBy
    ++
    ++Returns an L<RT::Group> object for this asset's I<HeldBy> role group.  The object
    ++may be unloaded if permissions aren't satisfied.
    ++
    ++=head2 Contacts
    ++
    ++Returns an L<RT::Group> object for this asset's I<Contact> role
    ++group.  The object may be unloaded if permissions aren't satisfied.
    ++
    ++=cut
    ++
    ++sub Owner {
    ++    my $self  = shift;
    ++    my $group = $self->RoleGroup("Owner");
    ++    return unless $group and $group->id;
    ++    return $group->UserMembersObj->First;
    ++}
    ++sub HeldBy   { $_[0]->RoleGroup("HeldBy")  }
    ++sub Contacts { $_[0]->RoleGroup("Contact") }
    ++
    ++=head2 AddRoleMember
    ++
    ++Checks I<ModifyAsset> before calling L<RT::Record::Role::Roles/_AddRoleMember>.
    ++
    ++=cut
    ++
    ++sub AddRoleMember {
    ++    my $self = shift;
    ++
    ++    return (0, $self->loc("No permission to modify this asset"))
    ++        unless $self->CurrentUserHasRight("ModifyAsset");
    ++
    ++    return $self->_AddRoleMember(@_);
    ++}
    ++
    ++=head2 DeleteRoleMember
    ++
    ++Checks I<ModifyAsset> before calling L<RT::Record::Role::Roles/_DeleteRoleMember>.
    ++
    ++=cut
    ++
    ++sub DeleteRoleMember {
    ++    my $self = shift;
    ++
    ++    return (0, $self->loc("No permission to modify this asset"))
    ++        unless $self->CurrentUserHasRight("ModifyAsset");
    ++
    ++    return $self->_DeleteRoleMember(@_);
    ++}
    ++
    ++=head2 RoleGroup
    ++
    ++An ACL'd version of L<RT::Record::Role::Roles/_RoleGroup>.  Checks I<ShowAsset>.
    ++
    ++=cut
    ++
    ++sub RoleGroup {
    ++    my $self = shift;
    ++    if ($self->CurrentUserCanSee) {
    ++        return $self->_RoleGroup(@_);
    ++    } else {
    ++        return RT::Group->new( $self->CurrentUser );
    ++    }
    ++}
    ++
    ++=head1 INTERNAL METHODS
    ++
    ++Public methods, but you shouldn't need to call these unless you're
    ++extending Assets.
    ++
    ++=head2 CustomFieldLookupType
    ++
    ++=cut
    ++
    ++sub CustomFieldLookupType { "RT::Catalog-RT::Asset" }
    ++
    ++=head2 ACLEquivalenceObjects
    ++
    ++=cut
    ++
    ++sub ACLEquivalenceObjects {
    ++    my $self = shift;
    ++    return $self->CatalogObj;
    ++}
    ++
    ++=head2 ModifyLinkRight
    ++
    ++=cut
    ++
    ++# Used for StrictLinkACL and RT::Record::Role::Links.
    ++#
    ++# Historically StrictLinkACL has only applied between tickets, but
    ++# if you care about it enough to turn it on, you probably care when
    ++# linking an asset to an asset or an asset to a ticket.
    ++
    ++sub ModifyLinkRight { "ShowAsset" }
    ++
    ++=head2 LoadCustomFieldByIdentifier
    ++
    ++Finds and returns the custom field of the given name for the asset,
    ++overriding L<RT::Record/LoadCustomFieldByIdentifier> to look for
    ++catalog-specific CFs before global ones.
    ++
    ++=cut
    ++
    ++sub LoadCustomFieldByIdentifier {
    ++    my $self  = shift;
    ++    my $field = shift;
    ++
    ++    return $self->SUPER::LoadCustomFieldByIdentifier($field)
    ++        if ref $field or $field =~ /^\d+$/;
    ++
    ++    my $cf = RT::CustomField->new( $self->CurrentUser );
    ++    $cf->SetContextObject( $self );
    ++    $cf->LoadByNameAndCatalog( Name => $field, Catalog => $self->Catalog );
    ++    $cf->LoadByNameAndCatalog( Name => $field, Catalog => 0 ) unless $cf->id;
    ++    return $cf;
    ++}
    ++
    ++=head1 PRIVATE METHODS
    ++
    ++Documented for internal use only, do not call these from outside RT::Asset
    ++itself.
    ++
    ++=head2 _Set
    ++
    ++Checks if the current user can I<ModifyAsset> before calling C<SUPER::_Set>
    ++and records a transaction against this object if C<SUPER::_Set> was
    ++successful.
    ++
    ++=cut
    ++
    ++sub _Set {
    ++    my $self = shift;
    ++    my %args = (
    ++        Field => undef,
    ++        Value => undef,
    ++        @_
    ++    );
    ++
    ++    return (0, $self->loc("Permission Denied"))
    ++        unless $self->CurrentUserHasRight('ModifyAsset');
    ++
    ++    my $old = $self->_Value( $args{'Field'} );
    ++
    ++    my ($ok, $msg) = $self->SUPER::_Set(@_);
    ++
    ++    # Only record the transaction if the _Set worked
    ++    return ($ok, $msg) unless $ok;
    ++
    ++    my $txn_type = $args{Field} eq "Status" ? "Status" : "Set";
    ++
    ++    my ($txn_id, $txn_msg, $txn) = $self->_NewTransaction(
    ++        Type     => $txn_type,
    ++        Field    => $args{'Field'},
    ++        NewValue => $args{'Value'},
    ++        OldValue => $old,
    ++    );
    ++
    ++    # Ensure that we can read the transaction, even if the change just made
    ++    # the asset unreadable to us.  This is only in effect for the lifetime of
    ++    # $txn, i.e. as soon as this method returns.
    ++    $txn->{ _object_is_readable } = 1;
    ++
    ++    return ($txn_id, scalar $txn->BriefDescription);
    ++}
    ++
    ++=head2 _Value
    ++
    ++Checks L</CurrentUserCanSee> before calling C<SUPER::_Value>.
    ++
    ++=cut
    ++
    ++sub _Value {
    ++    my $self = shift;
    ++    return unless $self->CurrentUserCanSee;
    ++    return $self->SUPER::_Value(@_);
    ++}
    ++
    ++sub Table { "Assets" }
    ++
    ++sub _CoreAccessible {
    ++    {
    ++        id            => { read => 1, type => 'int(11)',        default => '' },
    ++        Name          => { read => 1, type => 'varchar(255)',   default => '',  write => 1 },
    ++        Status        => { read => 1, type => 'varchar(64)',    default => '',  write => 1 },
    ++        Description   => { read => 1, type => 'varchar(255)',   default => '',  write => 1 },
    ++        Catalog       => { read => 1, type => 'int(11)',        default => '0', write => 1 },
    ++        Creator       => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
    ++        Created       => { read => 1, type => 'datetime',       default => '',  auto => 1 },
    ++        LastUpdatedBy => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
    ++        LastUpdated   => { read => 1, type => 'datetime',       default => '',  auto => 1 },
    ++    }
    ++}
    ++
    ++RT::Base->_ImportOverlays();
    ++
    ++1;
    +
    +diff --git a/lib/RT/Assets.pm b/lib/RT/Assets.pm
    +new file mode 100644
    +--- /dev/null
    ++++ b/lib/RT/Assets.pm
    +@@
    ++# BEGIN BPS TAGGED BLOCK {{{
    ++#
    ++# COPYRIGHT:
    ++#
    ++# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
    ++#                                          <sales at bestpractical.com>
    ++#
    ++# (Except where explicitly superseded by other copyright notices)
    ++#
    ++#
    ++# LICENSE:
    ++#
    ++# This work is made available to you under the terms of Version 2 of
    ++# the GNU General Public License. A copy of that license should have
    ++# been provided with this software, but in any event can be snarfed
    ++# from www.gnu.org.
    ++#
    ++# This work is distributed in the hope that it will be useful, but
    ++# WITHOUT ANY WARRANTY; without even the implied warranty of
    ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    ++# General Public License for more details.
    ++#
    ++# You should have received a copy of the GNU General Public License
    ++# along with this program; if not, write to the Free Software
    ++# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
    ++# 02110-1301 or visit their web page on the internet at
    ++# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
    ++#
    ++#
    ++# CONTRIBUTION SUBMISSION POLICY:
    ++#
    ++# (The following paragraph is not intended to limit the rights granted
    ++# to you to modify and distribute this software under the terms of
    ++# the GNU General Public License and is only of importance to you if
    ++# you choose to contribute your changes and enhancements to the
    ++# community by submitting them to Best Practical Solutions, LLC.)
    ++#
    ++# By intentionally submitting any modifications, corrections or
    ++# derivatives to this work, or any other work intended for use with
    ++# Request Tracker, to Best Practical Solutions, LLC, you confirm that
    ++# you are the copyright holder for those contributions and you grant
    ++# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
    ++# royalty-free, perpetual, license to use, copy, create derivative
    ++# works based on those contributions, and sublicense and distribute
    ++# those contributions and any derivatives thereof.
    ++#
    ++# END BPS TAGGED BLOCK }}}
    ++
    ++use strict;
    ++use warnings;
    ++
    ++package RT::Assets;
    ++use base 'RT::SearchBuilder';
    ++
    ++use Role::Basic "with";
    ++with "RT::SearchBuilder::Role::Roles" => { -rename => {RoleLimit => '_RoleLimit'}};
    ++
    ++use Scalar::Util qw/blessed/;
    ++
    ++=head1 NAME
    ++
    ++RT::Assets - a collection of L<RT::Asset> objects
    ++
    ++=head1 METHODS
    ++
    ++Only additional methods or overridden behaviour beyond the L<RT::SearchBuilder>
    ++(itself a L<DBIx::SearchBuilder>) class are documented below.
    ++
    ++=head2 LimitToActiveStatus
    ++
    ++=cut
    ++
    ++sub LimitToActiveStatus {
    ++    my $self = shift;
    ++
    ++    $self->Limit( FIELD => 'Status', VALUE => $_ )
    ++        for RT::Catalog->LifecycleObj->Valid('initial', 'active');
    ++}
    ++
    ++=head2 LimitCatalog
    ++
    ++Limit Catalog
    ++
    ++=cut
    ++
    ++sub LimitCatalog {
    ++    my $self = shift;
    ++    my %args = (
    ++        FIELD    => 'Catalog',
    ++        OPERATOR => '=',
    ++        @_
    ++    );
    ++
    ++    if ( $args{OPERATOR} eq '=' ) {
    ++        $self->{Catalog} = $args{VALUE};
    ++    }
    ++    $self->SUPER::Limit(%args);
    ++}
    ++
    ++=head2 Limit
    ++
    ++Defaults CASESENSITIVE to 0
    ++
    ++=cut
    ++
    ++sub Limit {
    ++    my $self = shift;
    ++    my %args = (
    ++        CASESENSITIVE => 0,
    ++        @_
    ++    );
    ++    $self->SUPER::Limit(%args);
    ++}
    ++
    ++=head2 RoleLimit
    ++
    ++Re-uses the underlying JOIN, if possible.
    ++
    ++=cut
    ++
    ++sub RoleLimit {
    ++    my $self = shift;
    ++    my %args = (
    ++        TYPE => '',
    ++        SUBCLAUSE => '',
    ++        OPERATOR => '=',
    ++        @_
    ++    );
    ++
    ++    my $key = "role-join-".join("-",map {$args{$_}//''} qw/SUBCLAUSE TYPE OPERATOR/);
    ++    my @ret = $self->_RoleLimit(%args, BUNDLE => $self->{$key} );
    ++    $self->{$key} = \@ret;
    ++}
    ++
    ++=head1 INTERNAL METHODS
    ++
    ++Public methods which encapsulate implementation details.  You shouldn't need to
    ++call these in normal code.
    ++
    ++=head2 AddRecord
    ++
    ++Checks the L<RT::Asset> is readable before adding it to the results
    ++
    ++=cut
    ++
    ++sub AddRecord {
    ++    my $self  = shift;
    ++    my $asset = shift;
    ++    return unless $asset->CurrentUserCanSee;
    ++
    ++    return if $asset->__Value('Status') eq 'deleted'
    ++        and not $self->{'allow_deleted_search'};
    ++
    ++    $self->SUPER::AddRecord($asset, @_);
    ++}
    ++
    ++=head2 NewItem
    ++
    ++Returns a new empty RT::Asset item
    ++
    ++=cut
    ++
    ++sub NewItem {
    ++    my $self = shift;
    ++    return RT::Asset->new( $self->CurrentUser );
    ++}
    ++
    ++=head1 PRIVATE METHODS
    ++
    ++=head2 _Init
    ++
    ++Sets default ordering by Name ascending.
    ++
    ++=cut
    ++
    ++sub _Init {
    ++    my $self = shift;
    ++
    ++    $self->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
    ++    return $self->SUPER::_Init( @_ );
    ++}
    ++
    ++sub SimpleSearch {
    ++    my $self = shift;
    ++    my %args = (
    ++        Fields      => RT->Config->Get('AssetSearchFields'),
    ++        Catalog     => RT->Config->Get('DefaultCatalog'),
    ++        Term        => undef,
    ++        @_
    ++    );
    ++
    ++    # XXX: We only search a single catalog so that we can map CF names
    ++    # to their ids, as searching CFs by CF name is rather complicated
    ++    # and currently fails in odd ways.  Such a mapping obviously assumes
    ++    # that names are unique within the catalog, but ids are also
    ++    # allowable as well.
    ++    my $catalog;
    ++    if (ref $args{Catalog}) {
    ++        $catalog = $args{Catalog};
    ++    } else {
    ++        $catalog = RT::Catalog->new( $self->CurrentUser );
    ++        $catalog->Load( $args{Catalog} );
    ++    }
    ++
    ++    my %cfs;
    ++    my $cfs = $catalog->AssetCustomFields;
    ++    while (my $customfield = $cfs->Next) {
    ++        $cfs{$customfield->id} = $cfs{$customfield->Name}
    ++            = $customfield;
    ++    }
    ++
    ++    $self->LimitCatalog( VALUE => $catalog->id );
    ++
    ++    while (my ($name, $op) = each %{$args{Fields}}) {
    ++        $op = 'STARTSWITH'
    ++            unless $op =~ /^(?:LIKE|(?:START|END)SWITH|=|!=)$/i;
    ++
    ++        if ($name =~ /^CF\.(?:\{(.*)}|(.*))$/) {
    ++            my $cfname = $1 || $2;
    ++            $self->LimitCustomField(
    ++                CUSTOMFIELD     => $cfs{$cfname},
    ++                OPERATOR        => $op,
    ++                VALUE           => $args{Term},
    ++                ENTRYAGGREGATOR => 'OR',
    ++                SUBCLAUSE       => 'autocomplete',
    ++            ) if $cfs{$cfname};
    ++        } elsif ($name eq 'id' and $op =~ /(?:LIKE|(?:START|END)SWITH)$/i) {
    ++            $self->Limit(
    ++                FUNCTION        => "CAST( main.$name AS TEXT )",
    ++                OPERATOR        => $op,
    ++                VALUE           => $args{Term},
    ++                ENTRYAGGREGATOR => 'OR',
    ++                SUBCLAUSE       => 'autocomplete',
    ++            ) if $args{Term} =~ /^\d+$/;
    ++        } else {
    ++            $self->Limit(
    ++                FIELD           => $name,
    ++                OPERATOR        => $op,
    ++                VALUE           => $args{Term},
    ++                ENTRYAGGREGATOR => 'OR',
    ++                SUBCLAUSE       => 'autocomplete',
    ++            ) unless $args{Term} =~ /\D/ and $name eq 'id';
    ++        }
    ++    }
    ++    return $self;
    ++}
    ++
    ++sub OrderByCols {
    ++    my $self = shift;
    ++    my @res  = ();
    ++
    ++    my $class = $self->_RoleGroupClass;
    ++
    ++    for my $row (@_) {
    ++        if ($row->{FIELD} =~ /^CF\.(?:\{(.*)\}|(.*))$/) {
    ++            my $name = $1 || $2;
    ++            my $cf = RT::CustomField->new( $self->CurrentUser );
    ++            $cf->LoadByNameAndCatalog(
    ++                Name => $name,
    ++                Catalog => $self->{'Catalog'},
    ++            );
    ++            if ( $cf->id ) {
    ++                push @res, $self->_OrderByCF( $row, $cf->id, $cf );
    ++            }
    ++        } elsif ($row->{FIELD} =~ /^(\w+)(?:\.(\w+))?$/) {
    ++            my ($role, $subkey) = ($1, $2);
    ++            if ($class->HasRole($role)) {
    ++                $self->{_order_by_role}{ $role }
    ++                        ||= ( $self->_WatcherJoin( Name => $role, Class => $class) )[2];
    ++                push @res, {
    ++                    %$row,
    ++                    ALIAS => $self->{_order_by_role}{ $role },
    ++                    FIELD => $subkey || 'EmailAddress',
    ++                };
    ++            } else {
    ++                push @res, $row;
    ++            }
    ++        } else {
    ++            push @res, $row;
    ++        }
    ++    }
    ++    return $self->SUPER::OrderByCols( @res );
    ++}
    ++
    ++=head2 _DoSearch
    ++
    ++=head2 _DoCount
    ++
    ++Limits to non-deleted assets unless the C<allow_deleted_search> flag is set.
    ++
    ++=cut
    ++
    ++sub _DoSearch {
    ++    my $self = shift;
    ++    $self->Limit( FIELD => 'Status', OPERATOR => '!=', VALUE => 'deleted', SUBCLAUSE => "not_deleted" )
    ++        unless $self->{'allow_deleted_search'};
    ++    $self->SUPER::_DoSearch(@_);
    ++}
    ++
    ++sub _DoCount {
    ++    my $self = shift;
    ++    $self->Limit( FIELD => 'Status', OPERATOR => '!=', VALUE => 'deleted', SUBCLAUSE => "not_deleted" )
    ++        unless $self->{'allow_deleted_search'};
    ++    $self->SUPER::_DoCount(@_);
    ++}
    ++
    ++sub Table { "Assets" }
    ++
    ++RT::Base->_ImportOverlays();
    ++
    ++1;
    +
    +diff --git a/lib/RT/Catalog.pm b/lib/RT/Catalog.pm
    +new file mode 100644
    +--- /dev/null
    ++++ b/lib/RT/Catalog.pm
    +@@
    ++# BEGIN BPS TAGGED BLOCK {{{
    ++#
    ++# COPYRIGHT:
    ++#
    ++# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
    ++#                                          <sales at bestpractical.com>
    ++#
    ++# (Except where explicitly superseded by other copyright notices)
    ++#
    ++#
    ++# LICENSE:
    ++#
    ++# This work is made available to you under the terms of Version 2 of
    ++# the GNU General Public License. A copy of that license should have
    ++# been provided with this software, but in any event can be snarfed
    ++# from www.gnu.org.
    ++#
    ++# This work is distributed in the hope that it will be useful, but
    ++# WITHOUT ANY WARRANTY; without even the implied warranty of
    ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    ++# General Public License for more details.
    ++#
    ++# You should have received a copy of the GNU General Public License
    ++# along with this program; if not, write to the Free Software
    ++# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
    ++# 02110-1301 or visit their web page on the internet at
    ++# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
    ++#
    ++#
    ++# CONTRIBUTION SUBMISSION POLICY:
    ++#
    ++# (The following paragraph is not intended to limit the rights granted
    ++# to you to modify and distribute this software under the terms of
    ++# the GNU General Public License and is only of importance to you if
    ++# you choose to contribute your changes and enhancements to the
    ++# community by submitting them to Best Practical Solutions, LLC.)
    ++#
    ++# By intentionally submitting any modifications, corrections or
    ++# derivatives to this work, or any other work intended for use with
    ++# Request Tracker, to Best Practical Solutions, LLC, you confirm that
    ++# you are the copyright holder for those contributions and you grant
    ++# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
    ++# royalty-free, perpetual, license to use, copy, create derivative
    ++# works based on those contributions, and sublicense and distribute
    ++# those contributions and any derivatives thereof.
    ++#
    ++# END BPS TAGGED BLOCK }}}
    ++
    ++use strict;
    ++use warnings;
    ++
    ++package RT::Catalog;
    ++use base 'RT::Record';
    ++
    ++use Role::Basic 'with';
    ++with "RT::Record::Role::Lifecycle",
    ++     "RT::Record::Role::Roles" => {
    ++         -rename => {
    ++             # We provide ACL'd wraps of these.
    ++             AddRoleMember    => "_AddRoleMember",
    ++             DeleteRoleMember => "_DeleteRoleMember",
    ++             RoleGroup        => "_RoleGroup",
    ++         },
    ++     },
    ++     "RT::Record::Role::Rights";
    ++
    ++require RT::ACE;
    ++
    ++=head1 NAME
    ++
    ++RT::Catalog - A logical set of assets
    ++
    ++=cut
    ++
    ++# For the Lifecycle role
    ++sub LifecycleType { "asset" }
    ++
    ++# Setup rights
    ++__PACKAGE__->AddRight( General => ShowCatalog  => 'See catalogs' ); #loc
    ++__PACKAGE__->AddRight( Admin   => AdminCatalog => 'Create, modify, and disable catalogs' ); #loc
    ++
    ++__PACKAGE__->AddRight( General => ShowAsset    => 'See assets' ); #loc
    ++__PACKAGE__->AddRight( Staff   => CreateAsset  => 'Create assets' ); #loc
    ++__PACKAGE__->AddRight( Staff   => ModifyAsset  => 'Modify assets' ); #loc
    ++
    ++__PACKAGE__->AddRight( General => SeeCustomField      => 'View custom field values' ); # loc
    ++__PACKAGE__->AddRight( Staff   => ModifyCustomField   => 'Modify custom field values' ); # loc
    ++
    ++RT::ACE->RegisterCacheHandler(sub {
    ++    my %args = (
    ++        Action      => "",
    ++        RightName   => "",
    ++        @_
    ++    );
    ++
    ++    return unless $args{Action}    =~ /^(Grant|Revoke)$/i
    ++              and $args{RightName} =~ /^(ShowCatalog|CreateAsset)$/;
    ++
    ++    RT::Catalog->CacheNeedsUpdate(1);
    ++});
    ++
    ++=head1 DESCRIPTION
    ++
    ++Catalogs are for assets what queues are for tickets or classes are for
    ++articles.
    ++
    ++It announces the rights for assets, and rights are granted at the catalog or
    ++global level.  Asset custom fields are either applied globally to all Catalogs
    ++or individually to specific Catalogs.
    ++
    ++=over 4
    ++
    ++=item id
    ++
    ++=item Name
    ++
    ++Limited to 255 characters.
    ++
    ++=item Description
    ++
    ++Limited to 255 characters.
    ++
    ++=item Lifecycle
    ++
    ++=item Disabled
    ++
    ++=item Creator
    ++
    ++=item Created
    ++
    ++=item LastUpdatedBy
    ++
    ++=item LastUpdated
    ++
    ++=back
    ++
    ++All of these are readable through methods of the same name and mutable through
    ++methods of the same name with C<Set> prefixed.  The last four are automatically
    ++managed.
    ++
    ++=head1 METHODS
    ++
    ++=head2 Load ID or NAME
    ++
    ++Loads the specified Catalog into the current object.
    ++
    ++=cut
    ++
    ++sub Load {
    ++    my $self = shift;
    ++    my $id   = shift;
    ++    return unless $id;
    ++
    ++    if ( $id =~ /\D/ ) {
    ++        return $self->LoadByCols( Name => $id );
    ++    }
    ++    else {
    ++        return $self->SUPER::Load($id);
    ++    }
    ++}
    ++
    ++=head2 Create PARAMHASH
    ++
    ++Create takes a hash of values and creates a row in the database.  Available keys are:
    ++
    ++=over 4
    ++
    ++=item Name
    ++
    ++=item Description
    ++
    ++=item Lifecycle
    ++
    ++=item HeldBy, Contact
    ++
    ++A single principal ID or array ref of principal IDs to add as members of the
    ++respective role groups for the new catalog.
    ++
    ++User Names and EmailAddresses may also be used, but Groups must be referenced
    ++by ID.
    ++
    ++=item Disabled
    ++
    ++=back
    ++
    ++Returns a tuple of (status, msg) on failure and (id, msg, non-fatal errors) on
    ++success, where the third value is an array reference of errors that occurred
    ++but didn't prevent creation.
    ++
    ++=cut
    ++
    ++sub Create {
    ++    my $self = shift;
    ++    my %args = (
    ++        Name            => '',
    ++        Description     => '',
    ++        Lifecycle       => 'assets',
    ++
    ++        HeldBy          => undef,
    ++        Contact         => undef,
    ++
    ++        Disabled        => 0,
    ++
    ++        @_
    ++    );
    ++    my @non_fatal_errors;
    ++
    ++    return (0, $self->loc("Permission Denied"))
    ++        unless $self->CurrentUserHasRight('AdminCatalog');
    ++
    ++    return (0, $self->loc('Invalid Name (names must be unique and may not be all digits)'))
    ++        unless $self->ValidateName( $args{'Name'} );
    ++
    ++    $args{'Lifecycle'} ||= 'assets';
    ++
    ++    return (0, $self->loc('[_1] is not a valid lifecycle', $args{'Lifecycle'}))
    ++        unless $self->ValidateLifecycle( $args{'Lifecycle'} );
    ++
    ++    RT->DatabaseHandle->BeginTransaction();
    ++
    ++    my ( $id, $msg ) = $self->SUPER::Create(
    ++        map { $_ => $args{$_} } qw(Name Description Lifecycle Disabled),
    ++    );
    ++    unless ($id) {
    ++        RT->DatabaseHandle->Rollback();
    ++        return (0, $self->loc("Catalog create failed: [_1]", $msg));
    ++    }
    ++
    ++    # Create role groups
    ++    unless ($self->_CreateRoleGroups()) {
    ++        RT->Logger->error("Couldn't create role groups for catalog ". $self->id);
    ++        RT->DatabaseHandle->Rollback();
    ++        return (0, $self->loc("Couldn't create role groups for catalog"));
    ++    }
    ++
    ++    # Figure out users for roles
    ++    my $roles = {};
    ++    push @non_fatal_errors, $self->_ResolveRoles( $roles, %args );
    ++    push @non_fatal_errors, $self->_AddRolesOnCreate( $roles, map { $_ => sub {1} } $self->Roles );
    ++
    ++    # Create transaction
    ++    my ( $txn_id, $txn_msg, $txn ) = $self->_NewTransaction( Type => 'Create' );
    ++    unless ($txn_id) {
    ++        RT->DatabaseHandle->Rollback();
    ++        return (0, $self->loc( 'Catalog Create txn failed: [_1]', $txn_msg ));
    ++    }
    ++
    ++    $self->CacheNeedsUpdate(1);
    ++    RT->DatabaseHandle->Commit();
    ++
    ++    return ($id, $self->loc('Catalog #[_1] created: [_2]', $self->id, $args{'Name'}), \@non_fatal_errors);
    ++}
    ++
    ++=head2 ValidateName NAME
    ++
    ++Requires that Names contain at least one non-digit and doesn't already exist.
    ++
    ++=cut
    ++
    ++sub ValidateName {
    ++    my $self = shift;
    ++    my $name = shift;
    ++    return 0 unless defined $name and length $name;
    ++    return 0 unless $name =~ /\D/;
    ++
    ++    my $catalog = RT::Catalog->new( RT->SystemUser );
    ++    $catalog->Load($name);
    ++    return 0 if $catalog->id;
    ++
    ++    return 1;
    ++}
    ++
    ++=head2 Delete
    ++
    ++Catalogs may not be deleted.  Always returns failure.
    ++
    ++You should disable the catalog instead using C<< $catalog->SetDisabled(1) >>.
    ++
    ++=cut
    ++
    ++sub Delete {
    ++    my $self = shift;
    ++    return (0, $self->loc("Catalogs may not be deleted"));
    ++}
    ++
    ++=head2 CurrentUserCanSee
    ++
    ++Returns true if the current user can see the catalog via the I<ShowCatalog> or
    ++I<AdminCatalog> rights.
    ++
    ++=cut
    ++
    ++sub CurrentUserCanSee {
    ++    my $self = shift;
    ++    return $self->CurrentUserHasRight('ShowCatalog')
    ++        || $self->CurrentUserHasRight('AdminCatalog');
    ++}
    ++
    ++=head2 Owner
    ++
    ++Returns an L<RT::User> object for this catalog's I<Owner> role group.  On error,
    ++returns undef.
    ++
    ++=head2 HeldBy
    ++
    ++Returns an L<RT::Group> object for this catalog's I<HeldBy> role group.  The object
    ++may be unloaded if permissions aren't satisfied.
    ++
    ++=head2 Contacts
    ++
    ++Returns an L<RT::Group> object for this catalog's I<Contact> role
    ++group.  The object may be unloaded if permissions aren't satisfied.
    ++
    ++=cut
    ++
    ++sub Owner {
    ++    my $self  = shift;
    ++    my $group = $self->RoleGroup("Owner");
    ++    return unless $group and $group->id;
    ++    return $group->UserMembersObj->First;
    ++}
    ++sub HeldBy   { $_[0]->RoleGroup("HeldBy")  }
    ++sub Contacts { $_[0]->RoleGroup("Contact") }
    ++
    ++=head2 AddRoleMember
    ++
    ++Checks I<AdminCatalog> before calling L<RT::Record::Role::Roles/_AddRoleMember>.
    ++
    ++=cut
    ++
    ++sub AddRoleMember {
    ++    my $self = shift;
    ++
    ++    return (0, $self->loc("No permission to modify this catalog"))
    ++        unless $self->CurrentUserHasRight("AdminCatalog");
    ++
    ++    return $self->_AddRoleMember(@_);
    ++}
    ++
    ++=head2 DeleteRoleMember
    ++
    ++Checks I<AdminCatalog> before calling L<RT::Record::Role::Roles/_DeleteRoleMember>.
    ++
    ++=cut
    ++
    ++sub DeleteRoleMember {
    ++    my $self = shift;
    ++
    ++    return (0, $self->loc("No permission to modify this catalog"))
    ++        unless $self->CurrentUserHasRight("AdminCatalog");
    ++
    ++    return $self->_DeleteRoleMember(@_);
    ++}
    ++
    ++=head2 RoleGroup
    ++
    ++An ACL'd version of L<RT::Record::Role::Roles/_RoleGroup>.  Checks I<ShowCatalog>.
    ++
    ++=cut
    ++
    ++sub RoleGroup {
    ++    my $self = shift;
    ++    if ($self->CurrentUserCanSee) {
    ++        return $self->_RoleGroup(@_);
    ++    } else {
    ++        return RT::Group->new( $self->CurrentUser );
    ++    }
    ++}
    ++
    ++=head2 AssetCustomFields
    ++
    ++Returns an L<RT::CustomFields> object containing all global and
    ++catalog-specific B<asset> custom fields.
    ++
    ++=cut
    ++
    ++sub AssetCustomFields {
    ++    my $self = shift;
    ++    my $cfs  = RT::CustomFields->new( $self->CurrentUser );
    ++    if ($self->CurrentUserCanSee) {
    ++        $cfs->SetContextObject( $self );
    ++        $cfs->LimitToGlobalOrObjectId( $self->Id );
    ++        $cfs->LimitToLookupType( RT::Asset->CustomFieldLookupType );
    ++        $cfs->ApplySortOrder;
    ++    } else {
    ++        $cfs->Limit( FIELD => 'id', VALUE => 0, SUBCLAUSE => 'acl' );
    ++    }
    ++    return ($cfs);
    ++}
    ++
    ++=head1 INTERNAL METHODS
    ++
    ++=head2 CacheNeedsUpdate
    ++
    ++Takes zero or one arguments.
    ++
    ++If a true argument is provided, marks any Catalog caches as needing an update.
    ++This happens when catalogs are created, disabled/enabled, or modified.  Returns
    ++nothing.
    ++
    ++If no arguments are provided, returns an epoch time that any catalog caches
    ++should be newer than.
    ++
    ++May be called as a class or object method.
    ++
    ++=cut
    ++
    ++sub CacheNeedsUpdate {
    ++    my $class  = shift;
    ++    my $update = shift;
    ++
    ++    if ($update) {
    ++        RT->System->SetAttribute(Name => 'CatalogCacheNeedsUpdate', Content => time);
    ++        return;
    ++    } else {
    ++        my $attribute = RT->System->FirstAttribute('CatalogCacheNeedsUpdate');
    ++        return $attribute ? $attribute->Content : 0;
    ++    }
    ++}
    ++
    ++=head1 PRIVATE METHODS
    ++
    ++Documented for internal use only, do not call these from outside RT::Catalog
    ++itself.
    ++
    ++=head2 _Set
    ++
    ++Checks if the current user can I<AdminCatalog> before calling C<SUPER::_Set>
    ++and records a transaction against this object if C<SUPER::_Set> was
    ++successful.
    ++
    ++=cut
    ++
    ++sub _Set {
    ++    my $self = shift;
    ++    my %args = (
    ++        Field => undef,
    ++        Value => undef,
    ++        @_
    ++    );
    ++
    ++    return (0, $self->loc("Permission Denied"))
    ++        unless $self->CurrentUserHasRight('AdminCatalog');
    ++
    ++    my $old = $self->_Value( $args{'Field'} );
    ++
    ++    my ($ok, $msg) = $self->SUPER::_Set(@_);
    ++
    ++    # Only record the transaction if the _Set worked
    ++    return ($ok, $msg) unless $ok;
    ++
    ++    my $txn_type = "Set";
    ++    if ($args{'Field'} eq "Disabled") {
    ++        if (not $old and $args{'Value'}) {
    ++            $txn_type = "Disabled";
    ++        }
    ++        elsif ($old and not $args{'Value'}) {
    ++            $txn_type = "Enabled";
    ++        }
    ++    }
    ++
    ++    $self->CacheNeedsUpdate(1);
    ++
    ++    my ($txn_id, $txn_msg, $txn) = $self->_NewTransaction(
    ++        Type     => $txn_type,
    ++        Field    => $args{'Field'},
    ++        NewValue => $args{'Value'},
    ++        OldValue => $old,
    ++    );
    ++    return ($txn_id, scalar $txn->BriefDescription);
    ++}
    ++
    ++=head2 _Value
    ++
    ++Checks L</CurrentUserCanSee> before calling C<SUPER::_Value>.
    ++
    ++=cut
    ++
    ++sub _Value {
    ++    my $self = shift;
    ++    return unless $self->CurrentUserCanSee;
    ++    return $self->SUPER::_Value(@_);
    ++}
    ++
    ++sub Table { "Catalogs" }
    ++
    ++sub _CoreAccessible {
    ++    {
    ++        id            => { read => 1, type => 'int(11)',        default => '' },
    ++        Name          => { read => 1, type => 'varchar(255)',   default => '',          write => 1 },
    ++        Description   => { read => 1, type => 'varchar(255)',   default => '',          write => 1 },
    ++        Lifecycle     => { read => 1, type => 'varchar(32)',    default => 'assets',    write => 1 },
    ++        Disabled      => { read => 1, type => 'int(2)',         default => '0',         write => 1 },
    ++        Creator       => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
    ++        Created       => { read => 1, type => 'datetime',       default => '',  auto => 1 },
    ++        LastUpdatedBy => { read => 1, type => 'int(11)',        default => '0', auto => 1 },
    ++        LastUpdated   => { read => 1, type => 'datetime',       default => '',  auto => 1 },
    ++    }
    ++}
    ++
    ++RT::Base->_ImportOverlays();
    ++
    ++1;
    +
    +diff --git a/lib/RT/Catalogs.pm b/lib/RT/Catalogs.pm
    +new file mode 100644
    +--- /dev/null
    ++++ b/lib/RT/Catalogs.pm
    +@@
    ++# BEGIN BPS TAGGED BLOCK {{{
    ++#
    ++# COPYRIGHT:
    ++#
    ++# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
    ++#                                          <sales at bestpractical.com>
    ++#
    ++# (Except where explicitly superseded by other copyright notices)
    ++#
    ++#
    ++# LICENSE:
    ++#
    ++# This work is made available to you under the terms of Version 2 of
    ++# the GNU General Public License. A copy of that license should have
    ++# been provided with this software, but in any event can be snarfed
    ++# from www.gnu.org.
    ++#
    ++# This work is distributed in the hope that it will be useful, but
    ++# WITHOUT ANY WARRANTY; without even the implied warranty of
    ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    ++# General Public License for more details.
    ++#
    ++# You should have received a copy of the GNU General Public License
    ++# along with this program; if not, write to the Free Software
    ++# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
    ++# 02110-1301 or visit their web page on the internet at
    ++# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
    ++#
    ++#
    ++# CONTRIBUTION SUBMISSION POLICY:
    ++#
    ++# (The following paragraph is not intended to limit the rights granted
    ++# to you to modify and distribute this software under the terms of
    ++# the GNU General Public License and is only of importance to you if
    ++# you choose to contribute your changes and enhancements to the
    ++# community by submitting them to Best Practical Solutions, LLC.)
    ++#
    ++# By intentionally submitting any modifications, corrections or
    ++# derivatives to this work, or any other work intended for use with
    ++# Request Tracker, to Best Practical Solutions, LLC, you confirm that
    ++# you are the copyright holder for those contributions and you grant
    ++# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
    ++# royalty-free, perpetual, license to use, copy, create derivative
    ++# works based on those contributions, and sublicense and distribute
    ++# those contributions and any derivatives thereof.
    ++#
    ++# END BPS TAGGED BLOCK }}}
    ++
    ++use strict;
    ++use warnings;
    ++
    ++package RT::Catalogs;
    ++use base 'RT::SearchBuilder';
    ++
    ++=head1 NAME
    ++
    ++RT::Catalogs - a collection of L<RT::Catalog> objects
    ++
    ++=head1 METHODS
    ++
    ++Only additional methods or overridden behaviour beyond the L<RT::SearchBuilder>
    ++(itself a L<DBIx::SearchBuilder>) class are documented below.
    ++
    ++=head2 Limit
    ++
    ++Defaults CASESENSITIVE to 0
    ++
    ++=cut
    ++
    ++sub Limit {
    ++    my $self = shift;
    ++    my %args = (
    ++        CASESENSITIVE => 0,
    ++        @_
    ++    );
    ++    $self->SUPER::Limit(%args);
    ++}
    ++
    ++=head1 INTERNAL METHODS
    ++
    ++Public methods which encapsulate implementation details.  You shouldn't need to
    ++call these in normal code.
    ++
    ++=head2 AddRecord
    ++
    ++Checks the L<RT::Catalog> is readable before adding it to the results
    ++
    ++=cut
    ++
    ++sub AddRecord {
    ++    my $self    = shift;
    ++    my $catalog = shift;
    ++    return unless $catalog->CurrentUserCanSee;
    ++
    ++    $self->SUPER::AddRecord($catalog, @_);
    ++}
    ++
    ++=head2 NewItem
    ++
    ++Returns a new empty RT::Catalog item
    ++
    ++=cut
    ++
    ++sub NewItem {
    ++    my $self = shift;
    ++    return RT::Catalog->new( $self->CurrentUser );
    ++}
    ++
    ++=head1 PRIVATE METHODS
    ++
    ++=head2 _Init
    ++
    ++Sets default ordering by Name ascending.
    ++
    ++=cut
    ++
    ++sub _Init {
    ++    my $self = shift;
    ++
    ++    $self->{'with_disabled_column'} = 1;
    ++
    ++    $self->OrderBy( FIELD => 'Name', ORDER => 'ASC' );
    ++    return $self->SUPER::_Init( @_ );
    ++}
    ++
    ++sub Table { "Catalogs" }
    ++
    ++RT::Base->_ImportOverlays();
    ++
    ++1;
    +
    +diff --git a/lib/RT/CustomField.pm b/lib/RT/CustomField.pm
    +--- a/lib/RT/CustomField.pm
    ++++ b/lib/RT/CustomField.pm
    +@@
    +     return $self->SUPER::__DependsOn( %args );
    + }
    + 
    ++=head2 LoadByNameAndCatalog
    ++
    ++Loads the described asset custom field, if one is found, into the current
    ++object.  This method only consults custom fields applied to L<RT::Catalog> for
    ++L<RT::Asset> objects.
    ++
    ++Takes a hash with the keys:
    ++
    ++=over
    ++
    ++=item Name
    ++
    ++A L<RT::CustomField> ID or Name which applies to L<assets|RT::Asset>.
    ++
    ++=item Catalog
    ++
    ++Optional.  An L<RT::Catalog> ID or Name.
    ++
    ++=back
    ++
    ++If Catalog is specified, only a custom field added to that Catalog will be loaded.
    ++
    ++If Catalog is C<0>, only global asset custom fields will be loaded.
    ++
    ++If no Catalog is specified, all asset custom fields are searched including
    ++global and catalog-specific CFs.
    ++
    ++Please note that this method may load a Disabled custom field if no others
    ++matching the same criteria are found.  Enabled CFs are preferentially loaded.
    ++
    ++=cut
    ++
    ++# To someday be merged into RT::CustomField::LoadByName
    ++sub LoadByNameAndCatalog {
    ++    my $self = shift;
    ++    my %args = (
    ++                Catalog => undef,
    ++                Name  => undef,
    ++                @_,
    ++               );
    ++
    ++    unless ( defined $args{'Name'} && length $args{'Name'} ) {
    ++        $RT::Logger->error("Couldn't load Custom Field without Name");
    ++        return wantarray ? (0, $self->loc("No name provided")) : 0;
    ++    }
    ++
    ++    # if we're looking for a catalog by name, make it a number
    ++    if ( defined $args{'Catalog'} && ($args{'Catalog'} =~ /\D/ || !$self->ContextObject) ) {
    ++        my $CatalogObj = RT::Catalog->new( $self->CurrentUser );
    ++        my ($ok, $msg) = $CatalogObj->Load( $args{'Catalog'} );
    ++        if ( $ok ){
    ++            $args{'Catalog'} = $CatalogObj->Id;
    ++        }
    ++        elsif ($args{'Catalog'}) {
    ++            RT::Logger->error("Unable to load catalog " . $args{'Catalog'} . $msg);
    ++            return (0, $msg);
    ++        }
    ++        $self->SetContextObject( $CatalogObj )
    ++          unless $self->ContextObject;
    ++    }
    ++
    ++    my $CFs = RT::CustomFields->new( $self->CurrentUser );
    ++    $CFs->SetContextObject( $self->ContextObject );
    ++    my $field = $args{'Name'} =~ /\D/? 'Name' : 'id';
    ++    $CFs->Limit( FIELD => $field, VALUE => $args{'Name'}, CASESENSITIVE => 0);
    ++
    ++    # Limit to catalog, if provided. This will also limit to RT::Asset types.
    ++    $CFs->LimitToCatalog( $args{'Catalog'} );
    ++
    ++    # When loading by name, we _can_ load disabled fields, but prefer
    ++    # non-disabled fields.
    ++    $CFs->FindAllRows;
    ++    $CFs->OrderByCols(
    ++                      {
    ++                       FIELD => "Disabled", ORDER => 'ASC' },
    ++                     );
    ++
    ++    # We only want one entry.
    ++    $CFs->RowsPerPage(1);
    ++
    ++    return (0, $self->loc("Not found")) unless my $first = $CFs->First;
    ++    return $self->LoadById( $first->id );
    ++}
    ++
    ++
    + RT::Base->_ImportOverlays();
    + 
    + 1;
    +
    +diff --git a/lib/RT/CustomFields.pm b/lib/RT/CustomFields.pm
    +--- a/lib/RT/CustomFields.pm
    ++++ b/lib/RT/CustomFields.pm
    +@@
    +     return $res;
    + }
    + 
    ++=head2 LimitToCatalog
    ++
    ++Takes a numeric L<RT::Catalog> ID.  Limits the L<RT::CustomFields> collection
    ++to only those fields applied directly to the specified catalog.  This limit is
    ++OR'd with other L</LimitToCatalog> and L<RT::CustomFields/LimitToObjectId>
    ++calls.
    ++
    ++Note that this will cause the collection to only return asset CFs.
    ++
    ++=cut
    ++
    ++sub LimitToCatalog  {
    ++    my $self = shift;
    ++    my $catalog = shift;
    ++
    ++    $self->Limit (ALIAS => $self->_OCFAlias,
    ++                  ENTRYAGGREGATOR => 'OR',
    ++                  FIELD => 'ObjectId',
    ++                  VALUE => "$catalog")
    ++      if defined $catalog;
    ++
    ++    $self->LimitToLookupType( RT::Asset->CustomFieldLookupType );
    ++    $self->ApplySortOrder;
    ++
    ++    unless ($self->ContextObject) {
    ++        my $obj = RT::Catalog->new( $self->CurrentUser );
    ++        $obj->Load( $catalog );
    ++        $self->SetContextObject( $obj );
    ++    }
    ++}
    ++
    + RT::Base->_ImportOverlays();
    + 
    + 1;
    +
    +diff --git a/lib/RT/Interface/Web.pm b/lib/RT/Interface/Web.pm
    +--- a/lib/RT/Interface/Web.pm
    ++++ b/lib/RT/Interface/Web.pm
    +@@
    +       forms.js
    +       event-registration.js
    +       late.js
    ++      assets.js
    +       /static/RichText/ckeditor.js
    +       }, RT->Config->Get('JSFiles');
    + }
    +@@
    +     return @map;
    + }
    + 
    ++sub LoadCatalog {
    ++    my $id = shift
    ++        or Abort(loc("No catalog specified."));
    ++
    ++    my $catalog = RT::Catalog->new( $session{CurrentUser} );
    ++    $catalog->Load($id);
    ++
    ++    Abort(loc("Unable to find catalog [_1]", $id))
    ++        unless $catalog->id;
    ++
    ++    Abort(loc("You don't have permission to view this catalog."))
    ++        unless $catalog->CurrentUserCanSee;
    ++
    ++    return $catalog;
    ++}
    ++
    ++sub LoadAsset {
    ++    my $id = shift
    ++        or Abort(loc("No asset ID specified."));
    ++
    ++    my $asset = RT::Asset->new( $session{CurrentUser} );
    ++    $asset->Load($id);
    ++
    ++    Abort(loc("Unable to find asset #[_1]", $id))
    ++        unless $asset->id;
    ++
    ++    Abort(loc("You don't have permission to view this asset."))
    ++        unless $asset->CurrentUserCanSee;
    ++
    ++    return $asset;
    ++}
    ++
    ++sub ProcessRoleMembers {
    ++    my $object = shift;
    ++    my %ARGS   = (@_);
    ++    my @results;
    ++
    ++    for my $arg (keys %ARGS) {
    ++        if ($arg =~ /^Add(User|Group)RoleMember$/) {
    ++            next unless $ARGS{$arg} and $ARGS{"$arg-Role"};
    ++
    ++            my ($ok, $msg) = $object->AddRoleMember(
    ++                Type => $ARGS{"$arg-Role"},
    ++                $1   => $ARGS{$arg},
    ++            );
    ++            push @results, $msg;
    ++        }
    ++        elsif ($arg =~ /^SetRoleMember-(.+)$/) {
    ++            my $role = $1;
    ++            my $group = $object->RoleGroup($role);
    ++            next unless $group->id and $group->SingleMemberRoleGroup;
    ++            next if $ARGS{$arg} eq $group->UserMembersObj->First->Name;
    ++            my ($ok, $msg) = $object->AddRoleMember(
    ++                Type => $role,
    ++                User => $ARGS{$arg} || 'Nobody',
    ++            );
    ++            push @results, $msg;
    ++        }
    ++        elsif ($arg =~ /^(Add|Remove)RoleMember-(.+)$/) {
    ++            my $role = $2;
    ++            my $method = $1 eq 'Add'? 'AddRoleMember' : 'DeleteRoleMember';
    ++
    ++            my $is = 'User';
    ++            if ( ($ARGS{"$arg-Type"}||'') =~ /^(User|Group)$/ ) {
    ++                $is = $1;
    ++            }
    ++
    ++            my ($ok, $msg) = $object->$method(
    ++                Type        => $role,
    ++                ($ARGS{$arg} =~ /\D/
    ++                    ? ($is => $ARGS{$arg})
    ++                    : (PrincipalId => $ARGS{$arg})
    ++                ),
    ++            );
    ++            push @results, $msg;
    ++        }
    ++        elsif ($arg =~ /^RemoveAllRoleMembers-(.+)$/) {
    ++            my $role = $1;
    ++            my $group = $object->RoleGroup($role);
    ++            next unless $group->id;
    ++
    ++            my $gms = $group->MembersObj;
    ++            while ( my $gm = $gms->Next ) {
    ++                my ($ok, $msg) = $object->DeleteRoleMember(
    ++                    Type        => $role,
    ++                    PrincipalId => $gm->MemberId,
    ++                );
    ++                push @results, $msg;
    ++            }
    ++        }
    ++    }
    ++    return @results;
    ++}
    ++
    ++
    ++# If provided a catalog, load it and return the object.
    ++# If no catalog is passed, load the first active catalog.
    ++
    ++sub LoadDefaultCatalog {
    ++    my $catalog = shift;
    ++    my $catalog_obj = RT::Catalog->new($session{CurrentUser});
    ++
    ++    if ( $catalog ){
    ++        $catalog_obj->Load($catalog);
    ++        RT::Logger->error("Unable to load catalog: " . $catalog)
    ++            unless $catalog_obj->Id;
    ++    }
    ++    elsif ( $session{'DefaultCatalog'} ){
    ++        $catalog_obj->Load($session{'DefaultCatalog'});
    ++        RT::Logger->error("Unable to load remembered catalog: " .
    ++                          $session{'DefaultCatalog'})
    ++            unless $catalog_obj->Id;
    ++    }
    ++    elsif ( RT->Config->Get("DefaultCatalog") ){
    ++        $catalog_obj->Load( RT->Config->Get("DefaultCatalog") );
    ++        RT::Logger->error("Unable to load default catalog: "
    ++                          . RT->Config->Get("DefaultCatalog"))
    ++            unless $catalog_obj->Id;
    ++    }
    ++    else {
    ++        # If no catalog, default to the first active catalog
    ++        my $catalogs = RT::Catalogs->new($session{CurrentUser});
    ++        $catalogs->UnLimit;
    ++        $catalog_obj = $catalogs->First();
    ++        RT::Logger->error("No active catalogs.")
    ++            unless $catalog_obj and $catalog_obj->Id;
    ++    }
    ++
    ++    return $catalog_obj;
    ++}
    ++
    ++sub ProcessAssetsSearchArguments {
    ++    my %args = (
    ++        Catalog => undef,
    ++        Assets => undef,
    ++        ARGSRef => undef,
    ++        @_
    ++    );
    ++    my $ARGSRef = $args{'ARGSRef'};
    ++
    ++    my @PassArguments;
    ++
    ++    if ($ARGSRef->{q}) {
    ++        if ($ARGSRef->{q} =~ /^\d+$/) {
    ++            my $asset = RT::Asset->new( $session{CurrentUser} );
    ++            $asset->Load( $ARGSRef->{q} );
    ++            RT::Interface::Web::Redirect(
    ++                RT->Config->Get('WebURL')."Asset/Display.html?id=".$ARGSRef->{q}
    ++            ) if $asset->id;
    ++        }
    ++        $args{'Assets'}->SimpleSearch( Term => $ARGSRef->{q}, Catalog => $args{Catalog} );
    ++        push @PassArguments, "q";
    ++    } elsif ( $ARGSRef->{'SearchAssets'} ){
    ++        for my $key (keys %$ARGSRef) {
    ++            my $value = ref $ARGSRef->{$key} ? $ARGSRef->{$key}[0] : $ARGSRef->{$key};
    ++            next unless defined $value and length $value;
    ++
    ++            my $orig_key = $key;
    ++            my $negative = ($key =~ s/^!// ? 1 : 0);
    ++            if ($key =~ /^(Name|Description)$/) {
    ++                $args{'Assets'}->Limit(
    ++                    FIELD => $key,
    ++                    OPERATOR => ($negative ? 'NOT LIKE' : 'LIKE'),
    ++                    VALUE => $value,
    ++                    ENTRYAGGREGATOR => "AND",
    ++                );
    ++            } elsif ($key eq 'Catalog') {
    ++                $args{'Assets'}->LimitCatalog(
    ++                    OPERATOR => ($negative ? '!=' : '='),
    ++                    VALUE => $value,
    ++                    ENTRYAGGREGATOR => "AND",
    ++                );
    ++            } elsif ($key eq 'Status') {
    ++                $args{'Assets'}->Limit(
    ++                    FIELD => $key,
    ++                    OPERATOR => ($negative ? '!=' : '='),
    ++                    VALUE => $value,
    ++                    ENTRYAGGREGATOR => "AND",
    ++                );
    ++            } elsif ($key =~ /^Role\.(.+)/) {
    ++                my $role = $1;
    ++                $args{'Assets'}->RoleLimit(
    ++                    TYPE      => $role,
    ++                    FIELD     => $_,
    ++                    OPERATOR  => ($negative ? '!=' : '='),
    ++                    VALUE     => $value,
    ++                    SUBCLAUSE => $role,
    ++                    ENTRYAGGREGATOR => ($negative ? "AND" : "OR"),
    ++                    CASESENSITIVE   => 0,
    ++                ) for qw/EmailAddress Name/;
    ++            } elsif ($key =~ /^CF\.\{(.+?)\}$/ or $key =~ /^CF\.(.*)/) {
    ++                my $cf = RT::Asset->new( $session{CurrentUser} )
    ++                  ->LoadCustomFieldByIdentifier( $1 );
    ++                next unless $cf->id;
    ++                if ( $value eq 'NULL' ) {
    ++                    $args{'Assets'}->LimitCustomField(
    ++                        CUSTOMFIELD => $cf->Id,
    ++                        OPERATOR    => ($negative ? "IS NOT" : "IS"),
    ++                        VALUE       => 'NULL',
    ++                        QUOTEVALUE  => 0,
    ++                        ENTRYAGGREGATOR => "AND",
    ++                    );
    ++                } else {
    ++                    $args{'Assets'}->LimitCustomField(
    ++                        CUSTOMFIELD => $cf->Id,
    ++                        OPERATOR    => ($negative ? "NOT LIKE" : "LIKE"),
    ++                        VALUE       => $value,
    ++                        ENTRYAGGREGATOR => "AND",
    ++                    );
    ++                }
    ++            }
    ++            else {
    ++                next;
    ++            }
    ++            push @PassArguments, $orig_key;
    ++        }
    ++        push @PassArguments, 'SearchAssets';
    ++    }
    ++
    ++    my $Format = RT->Config->Get('AssetSearchFormat');
    ++    $Format = $Format->{$args{'Catalog'}->id}
    ++        || $Format->{$args{'Catalog'}->Name}
    ++        || $Format->{''} if ref $Format;
    ++    $Format ||= q[
    ++        '<b><a href="__WebPath__/Asset/Display.html?id=__id__">__id__</a></b>/TITLE:#',
    ++        '<b><a href="__WebPath__/Asset/Display.html?id=__id__">__Name__</a></b>/TITLE:Name',
    ++        Description,
    ++        Status,
    ++    ];
    ++
    ++    $ARGSRef->{OrderBy} ||= 'id';
    ++
    ++    push @PassArguments, qw/OrderBy Order Page/;
    ++
    ++    return (
    ++        OrderBy         => 'id',
    ++        Order           => 'ASC',
    ++        Rows            => 50,
    ++        (map { $_ => $ARGSRef->{$_} } grep { defined $ARGSRef->{$_} } @PassArguments),
    ++        PassArguments   => \@PassArguments,
    ++        Format          => $Format,
    ++    );
    ++}
    ++
    + =head2 _load_container_object ( $type, $id );
    + 
    + Instantiate container object for saving searches.
    +
    +diff --git a/lib/RT/Lifecycle/Asset.pm b/lib/RT/Lifecycle/Asset.pm
    +new file mode 100644
    +--- /dev/null
    ++++ b/lib/RT/Lifecycle/Asset.pm
    +@@
    ++# BEGIN BPS TAGGED BLOCK {{{
    ++#
    ++# COPYRIGHT:
    ++#
    ++# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
    ++#                                          <sales at bestpractical.com>
    ++#
    ++# (Except where explicitly superseded by other copyright notices)
    ++#
    ++#
    ++# LICENSE:
    ++#
    ++# This work is made available to you under the terms of Version 2 of
    ++# the GNU General Public License. A copy of that license should have
    ++# been provided with this software, but in any event can be snarfed
    ++# from www.gnu.org.
    ++#
    ++# This work is distributed in the hope that it will be useful, but
    ++# WITHOUT ANY WARRANTY; without even the implied warranty of
    ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    ++# General Public License for more details.
    ++#
    ++# You should have received a copy of the GNU General Public License
    ++# along with this program; if not, write to the Free Software
    ++# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
    ++# 02110-1301 or visit their web page on the internet at
    ++# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
    ++#
    ++#
    ++# CONTRIBUTION SUBMISSION POLICY:
    ++#
    ++# (The following paragraph is not intended to limit the rights granted
    ++# to you to modify and distribute this software under the terms of
    ++# the GNU General Public License and is only of importance to you if
    ++# you choose to contribute your changes and enhancements to the
    ++# community by submitting them to Best Practical Solutions, LLC.)
    ++#
    ++# By intentionally submitting any modifications, corrections or
    ++# derivatives to this work, or any other work intended for use with
    ++# Request Tracker, to Best Practical Solutions, LLC, you confirm that
    ++# you are the copyright holder for those contributions and you grant
    ++# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
    ++# royalty-free, perpetual, license to use, copy, create derivative
    ++# works based on those contributions, and sublicense and distribute
    ++# those contributions and any derivatives thereof.
    ++#
    ++# END BPS TAGGED BLOCK }}}
    ++
    ++use strict;
    ++use warnings;
    ++
    ++package RT::Lifecycle::Asset;
    ++__PACKAGE__->RegisterRights;
    ++
    ++use base qw(RT::Lifecycle);
    ++
    ++=head2 Catalogs
    ++
    ++Returns L<RT::Catalogs> collection with catalogs that use this lifecycle.
    ++
    ++=cut
    ++
    ++sub Catalogs {
    ++    my $self = shift;
    ++    require RT::Catalogs;
    ++    my $catalogs = RT::Catalogs->new( RT->SystemUser );
    ++    $catalogs->Limit( FIELD => 'Lifecycle', VALUE => $self->Name );
    ++    return $catalogs;
    ++}
    ++
    ++=head2 RegisterRights
    ++
    ++Asset lifecycle rights are registered (and thus grantable) at the
    ++catalog level.
    ++
    ++=cut
    ++
    ++sub RegisterRights {
    ++    my $self = shift;
    ++
    ++    my %rights = $self->RightsDescription( 'asset' );
    ++
    ++    require RT::ACE;
    ++
    ++    while ( my ($right, $description) = each %rights ) {
    ++        next if RT::ACE->CanonicalizeRightName( $right );
    ++
    ++        RT::Catalog->AddRight( Status => $right => $description );
    ++    }
    ++}
    ++
    ++1;
    +
    +diff --git a/lib/RT/Test/Assets.pm b/lib/RT/Test/Assets.pm
    +new file mode 100644
    +--- /dev/null
    ++++ b/lib/RT/Test/Assets.pm
    +@@
    ++use strict;
    ++use warnings;
    ++
    ++package RT::Test::Assets;
    ++use base 'RT::Test';
    ++
    ++our @EXPORT = qw(create_catalog create_asset create_assets create_cf apply_cfs);
    ++
    ++sub import {
    ++    my $class = shift;
    ++    my %args  = @_;
    ++
    ++    $class->SUPER::import( %args );
    ++    __PACKAGE__->export_to_level(1);
    ++}
    ++
    ++sub diag {
    ++    Test::More::diag(@_) if $ENV{TEST_VERBOSE};
    ++}
    ++
    ++sub create_catalog {
    ++    my %info  = @_;
    ++    my $catalog = RT::Catalog->new( RT->SystemUser );
    ++    my ($id, $msg) = $catalog->Create( %info );
    ++    if ($id) {
    ++        diag("Created catalog #$id: " . $catalog->Name);
    ++        return $catalog;
    ++    } else {
    ++        my $spec = join "/", map { "$_=$info{$_}" } keys %info;
    ++        RT->Logger->error("Failed to create catalog ($spec): $msg");
    ++        return;
    ++    }
    ++}
    ++
    ++sub create_asset {
    ++    my %info  = @_;
    ++    my $asset = RT::Asset->new( RT->SystemUser );
    ++    my ($id, $msg) = $asset->Create( %info );
    ++    if ($id) {
    ++        diag("Created asset #$id: " . $asset->Name);
    ++        return $asset;
    ++    } else {
    ++        my $spec = join "/", map { "$_=$info{$_}" } keys %info;
    ++        RT->Logger->error("Failed to create asset ($spec): $msg");
    ++        return;
    ++    }
    ++}
    ++
    ++sub create_assets {
    ++    my $error = 0;
    ++    for my $info (@_) {
    ++        create_asset(%$info)
    ++            or $error++;
    ++    }
    ++    return not $error;
    ++}
    ++
    ++sub create_cf {
    ++    my %args = (
    ++        Name        => "Test Asset CF ".($$ + rand(1024)),
    ++        Type        => "FreeformSingle",
    ++        LookupType  => RT::Asset->CustomFieldLookupType,
    ++        @_,
    ++    );
    ++    my $cf = RT::CustomField->new( RT->SystemUser );
    ++    my ($ok, $msg) = $cf->Create(%args);
    ++    RT->Logger->error("Can't create CF: $msg") unless $ok;
    ++    return $cf;
    ++}
    ++
    ++sub apply_cfs {
    ++    my $success = 1;
    ++    for my $cf (@_) {
    ++        my ($ok, $msg) = $cf->AddToObject( RT::Catalog->new(RT->SystemUser) );
    ++        if (not $ok) {
    ++            RT->Logger->error("Couldn't apply CF: $msg");
    ++            $success = 0;
    ++        }
    ++    }
    ++    return $success;
    ++}
    ++
    ++1;
    +
    +diff --git a/lib/RT/Transaction.pm b/lib/RT/Transaction.pm
    +--- a/lib/RT/Transaction.pm
    ++++ b/lib/RT/Transaction.pm
    +@@
    +             "#reminder-", $ticket->id, \'">', $ticket->Subject, \'</a>'
    +         ];
    +         return ("Reminder '[_1]' completed", $subject); #loc()
    +-    }
    ++    },
    ++    'RT::Asset-Set-Catalog' => sub {
    ++        my $self = shift;
    ++        return ("[_1] changed from [_2] to [_3]",   #loc
    ++                $self->loc($self->Field), map {
    ++                    my $c = RT::Catalog->new($self->CurrentUser);
    ++                    $c->Load($_);
    ++                    $c->Name || $self->loc("~[a hidden catalog~]")
    ++                } $self->OldValue, $self->NewValue);
    ++    },
    + );
    + 
    + 
    +
    +diff --git a/lib/RT/URI/asset.pm b/lib/RT/URI/asset.pm
    +new file mode 100644
    +--- /dev/null
    ++++ b/lib/RT/URI/asset.pm
    +@@
    ++# BEGIN BPS TAGGED BLOCK {{{
    ++#
    ++# COPYRIGHT:
    ++#
    ++# This software is Copyright (c) 1996-2014 Best Practical Solutions, LLC
    ++#                                          <sales at bestpractical.com>
    ++#
    ++# (Except where explicitly superseded by other copyright notices)
    ++#
    ++#
    ++# LICENSE:
    ++#
    ++# This work is made available to you under the terms of Version 2 of
    ++# the GNU General Public License. A copy of that license should have
    ++# been provided with this software, but in any event can be snarfed
    ++# from www.gnu.org.
    ++#
    ++# This work is distributed in the hope that it will be useful, but
    ++# WITHOUT ANY WARRANTY; without even the implied warranty of
    ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
    ++# General Public License for more details.
    ++#
    ++# You should have received a copy of the GNU General Public License
    ++# along with this program; if not, write to the Free Software
    ++# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
    ++# 02110-1301 or visit their web page on the internet at
    ++# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
    ++#
    ++#
    ++# CONTRIBUTION SUBMISSION POLICY:
    ++#
    ++# (The following paragraph is not intended to limit the rights granted
    ++# to you to modify and distribute this software under the terms of
    ++# the GNU General Public License and is only of importance to you if
    ++# you choose to contribute your changes and enhancements to the
    ++# community by submitting them to Best Practical Solutions, LLC.)
    ++#
    ++# By intentionally submitting any modifications, corrections or
    ++# derivatives to this work, or any other work intended for use with
    ++# Request Tracker, to Best Practical Solutions, LLC, you confirm that
    ++# you are the copyright holder for those contributions and you grant
    ++# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
    ++# royalty-free, perpetual, license to use, copy, create derivative
    ++# works based on those contributions, and sublicense and distribute
    ++# those contributions and any derivatives thereof.
    ++#
    ++# END BPS TAGGED BLOCK }}}
    ++
    ++use strict;
    ++use warnings;
    ++
    ++package RT::URI::asset;
    ++use base qw/RT::URI::base/;
    ++
    ++require RT::Asset;
    ++
    ++=head1 NAME
    ++
    ++RT::URI::asset - Internal URIs for linking to an L<RT::Asset>
    ++
    ++=head1 DESCRIPTION
    ++
    ++This class should rarely be used directly, but via L<RT::URI> instead.
    ++
    ++Represents, parses, and generates internal RT URIs such as:
    ++
    ++    asset:42
    ++    asset://example.com/42
    ++
    ++These URIs are used to link between objects in RT such as associating an asset
    ++with a ticket or an asset with another asset.
    ++
    ++=head1 METHODS
    ++
    ++Much of the interface below is dictated by L<RT::URI> and L<RT::URI::base>.
    ++
    ++=head2 Scheme
    ++
    ++Return the URI scheme for assets
    ++
    ++=cut
    ++
    ++sub Scheme { "asset" }
    ++
    ++=head2 LocalURIPrefix
    ++
    ++Returns the site-specific prefix for a local asset URI
    ++
    ++=cut
    ++
    ++sub LocalURIPrefix {
    ++    my $self = shift;
    ++    return $self->Scheme . "://" . RT->Config->Get('Organization') . "/";
    ++}
    ++
    ++=head2 IsLocal
    ++
    ++Returns a true value, the asset ID, if this object represents a local asset,
    ++undef otherwise.
    ++
    ++=cut
    ++
    ++sub IsLocal {
    ++    my $self   = shift;
    ++    my $prefix = $self->LocalURIPrefix;
    ++    return $1 if $self->{uri} =~ /^\Q$prefix\E(\d+)/i;
    ++    return undef;
    ++}
    ++
    ++=head2 URIForObject RT::Asset
    ++
    ++Returns the URI for a local L<RT::Asset> object
    ++
    ++=cut
    ++
    ++sub URIForObject {
    ++    my $self = shift;
    ++    my $obj  = shift;
    ++    return $self->LocalURIPrefix . $obj->Id;
    ++}
    ++
    ++=head2 ParseURI URI
    ++
    ++Primarily used by L<RT::URI> to set internal state.
    ++
    ++Figures out from an C<asset:> URI whether it refers to a local asset and the
    ++asset ID.
    ++
    ++Returns the asset ID if local, otherwise returns false.
    ++
    ++=cut
    ++
    ++sub ParseURI {
    ++    my $self = shift;
    ++    my $uri  = shift;
    ++
    ++    my $scheme = $self->Scheme;
    ++
    ++    # canonicalize "42" and "asset:42" -> asset://example.com/42
    ++    if ($uri =~ /^(?:\Q$scheme\E:)?(\d+)$/i) {
    ++        $self->{'uri'} = $self->LocalURIPrefix . $1;
    ++    }
    ++    else {
    ++        $self->{'uri'} = $uri;
    ++    }
    ++
    ++    my $asset = RT::Asset->new( $self->CurrentUser );
    ++    if ( my $id = $self->IsLocal ) {
    ++        $asset->Load($id);
    ++
    ++        if ($asset->id) {
    ++            $self->{'object'} = $asset;
    ++        } else {
    ++            RT->Logger->error("Can't load Asset #$id by URI '$uri'");
    ++            return;
    ++        }
    ++    }
    ++    return $asset->id;
    ++}
    ++
    ++=head2 Object
    ++
    ++Returns the object for this URI, if it's local. Otherwise returns undef.
    ++
    ++=cut
    ++
    ++sub Object {
    ++    my $self = shift;
    ++    return $self->{'object'};
    ++}
    ++
    ++=head2 HREF
    ++
    ++If this is a local asset, return an HTTP URL for it.
    ++
    ++Otherwise, return its URI.
    ++
    ++=cut
    ++
    ++sub HREF {
    ++    my $self = shift;
    ++    if ($self->IsLocal and $self->Object) {
    ++        return RT->Config->Get('WebURL')
    ++             . ( $self->CurrentUser->Privileged ? "" : "SelfService/" )
    ++             . "Asset/Display.html?id="
    ++             . $self->Object->Id;
    ++    } else {
    ++        return $self->URI;
    ++    }
    ++}
    ++
    ++=head2 AsString
    ++
    ++Returns a description of this object
    ++
    ++=cut
    ++
    ++sub AsString {
    ++    my $self = shift;
    ++    if ($self->IsLocal and $self->Object) {
    ++        my $object = $self->Object;
    ++        if ( $object->Name ) {
    ++            return $self->loc('Asset #[_1]: [_2]', $object->id, $object->Name);
    ++        } else {
    ++            return $self->loc('Asset #[_1]', $object->id);
    ++        }
    ++    } else {
    ++        return $self->SUPER::AsString(@_);
    ++    }
    ++}
    ++
    ++1;
     
     diff --git a/share/html/Admin/Assets/Catalogs/Create.html b/share/html/Admin/Assets/Catalogs/Create.html
     new file mode 100644
    @@ -3904,6 +7679,18 @@
     +    ],
     +    AllowSorting    => 0,
     +    &>
    +
    +diff --git a/share/html/Elements/AddLinks b/share/html/Elements/AddLinks
    +--- a/share/html/Elements/AddLinks
    ++++ b/share/html/Elements/AddLinks
    +@@
    + % if (ref($Object) eq 'RT::Ticket') {
    + <i><&|/l&>Enter tickets or URIs to link tickets to. Separate multiple entries with spaces.</&>
    + <br /><&|/l&>You may enter links to Articles as "a:###", where ### represents the number of the Article.</&>
    ++<br /><&|/l&>Enter links to assets as "asset:###", where ### represents the asset ID.</&>
    + % $m->callback( CallbackName => 'ExtraLinkInstructions' );
    + </i><br />
    + % } elsif (ref($Object) eq 'RT::Queue') {
     
     diff --git a/share/html/Elements/Assets/AddPeople b/share/html/Elements/Assets/AddPeople
     new file mode 100644
    @@ -4578,6 +8365,196 @@
     +return GetColumnMapEntry( Map => $COLUMN_MAP, Name => $Name, Attribute => $Attr );
     +</%init>
     
    +diff --git a/share/html/Elements/Tabs b/share/html/Elements/Tabs
    +--- a/share/html/Elements/Tabs
    ++++ b/share/html/Elements/Tabs
    +@@
    +         PageMenu()->child( edit => title => loc('Edit'), path => '/Prefs/MyRT.html' );
    +     }
    + 
    ++    { ### START ASSETS MENU
    ++        # Top level Assets menu
    ++        my $assets = Menu->child("tools")->add_before(
    ++                       "assets", title => loc("Assets"), path => "/Asset/Search/");
    ++        $assets->child("create", title => loc("Create"), path => "/Asset/CreateInCatalog.html");
    ++        $assets->child("search", title => loc("Search"), path => "/Asset/Search/");
    ++
    ++        Menu->child("search")->child("assets", title => loc("Assets"), path => "/Asset/Search/");
    ++
    ++        # Add global Assets custom field admin page
    ++        my $global_cfs = Menu();
    ++           $global_cfs = $global_cfs->child($_) or last
    ++                for "admin" => "global" => "custom-fields";
    ++        $global_cfs->child("assets", title => loc("Assets"), path => "/Admin/Global/CustomFields/Catalog-Assets.html")
    ++            if $global_cfs;
    ++
    ++        # Add a Catalog admin menu
    ++        my $config = Menu()->child("admin");
    ++        if ($config) {
    ++            my $assets   = $config->child("tools")->add_before("assets", title => loc("Assets"), path => "/Admin/Assets/");
    ++            my $catalogs = $assets->child("catalogs",
    ++                title => loc("Catalogs"),
    ++                description => loc("Modify asset catalogs"),
    ++                path => "/Admin/Assets/Catalogs/");
    ++            $catalogs->child("select", title => loc("Select"), path => $catalogs->path);
    ++            $catalogs->child("create", title => loc("Create"), path => "Create.html");
    ++
    ++            my $cfs = $assets->child("cfs",
    ++                title => loc("Custom Fields"),
    ++                description => loc("Modify asset custom fields"),
    ++                path => "/Admin/CustomFields/?Type=" . RT::Asset->CustomFieldLookupType);
    ++            $cfs->child("select", title => loc("Select"), path => $cfs->path);
    ++            $cfs->child("create", title => loc("Create"), path => "/Admin/CustomFields/Modify.html?Create=1&LookupType=" . RT::Asset->CustomFieldLookupType);
    ++        }
    ++
    ++        # Asset search
    ++        if ($request_path =~ m{^/Asset/}) {
    ++            PageWidgets()->child( asset_search => raw_html => $m->scomp('/Elements/Assets/Search') );
    ++            PageWidgets()->delete('create_ticket');
    ++            PageWidgets()->delete('simple_search');
    ++        }
    ++
    ++        # Page menus
    ++        my $page    = PageMenu();
    ++
    ++        if ($request_path =~ m{^/Asset/} and $DECODED_ARGS->{id} and $DECODED_ARGS->{id} !~ /\D/) {
    ++            my $id    = $DECODED_ARGS->{id};
    ++            my $asset = RT::Asset->new( $session{CurrentUser} );
    ++            $asset->Load($id);
    ++
    ++            if ($asset->id) {
    ++                $page->child("display",     title => loc("Display"),        path => "/Asset/Display.html?id=$id");
    ++                $page->child("history",     title => loc("History"),        path => "/Asset/History.html?id=$id");
    ++                $page->child("basics",      title => loc("Basics"),         path => "/Asset/Modify.html?id=$id");
    ++                $page->child("links",       title => loc("Links"),          path => "/Asset/ModifyLinks.html?id=$id");
    ++                $page->child("people",      title => loc("People"),         path => "/Asset/ModifyPeople.html?id=$id");
    ++                $page->child("dates",       title => loc("Dates"),          path => "/Asset/ModifyDates.html?id=$id");
    ++
    ++                for my $grouping (RT::CustomField->CustomGroupings($asset)) {
    ++                    my $cfs = $asset->CustomFields;
    ++                    $cfs->LimitToGrouping( $asset => $grouping );
    ++                    next unless $cfs->Count;
    ++                    $page->child(
    ++                        "cf-grouping-$grouping",
    ++                        title   => loc($grouping),
    ++                        path    => "/Asset/ModifyCFs.html?id=$id;Grouping=" . $m->interp->apply_escapes($grouping, 'u'),
    ++                    );
    ++                }
    ++
    ++                my $actions = $page->child("actions", title => loc("Actions"));
    ++                $actions->child("create-linked-ticket", title => loc("Create linked ticket"), path => "/Asset/CreateLinkedTicket.html?Asset=$id");
    ++
    ++                my $status    = $asset->Status;
    ++                my $lifecycle = $asset->LifecycleObj;
    ++                for my $action ( $lifecycle->Actions($status) ) {
    ++                    my $next = $action->{'to'};
    ++                    next unless $lifecycle->IsTransition( $status => $next );
    ++
    ++                    my $check = $lifecycle->CheckRight( $status => $next );
    ++                    next unless $asset->CurrentUserHasRight($check);
    ++
    ++                    my $label = $action->{'label'} || ucfirst($next);
    ++                    $actions->child(
    ++                        $label,
    ++                        title   => loc($label),
    ++                        path    => "/Asset/Modify.html?id=$id;Update=1;DisplayAfter=1;Status="
    ++                                    . $m->interp->apply_escapes($next, 'u'),
    ++
    ++                        class       => "asset-lifecycle-action",
    ++                        attributes  => {
    ++                            'data-current-status'   => $status,
    ++                            'data-next-status'      => $next,
    ++                        },
    ++                    );
    ++                }
    ++            }
    ++        }
    ++        elsif ($request_path =~ m{^/Asset/Search/}) {
    ++            my %search = map @{$_},
    ++                grep defined $_->[1] && length $_->[1],
    ++                map {ref $DECODED_ARGS->{$_} ? [$_, $DECODED_ARGS->{$_}[0]] : [$_, $DECODED_ARGS->{$_}] }
    ++                grep /^(?:q|SearchAssets|!?(Name|Description|Catalog|Status|Role\..+|CF\..+)|Order(?:By)?|Page)$/,
    ++                keys %$DECODED_ARGS;
    ++            if ( $request_path =~ /Bulk/) {
    ++                $page->child('search',
    ++                    title => loc('Show Results'),
    ++                    path => '/Asset/Search/?'. $query_string->(%search),
    ++                );
    ++            } else {
    ++                $page->child('bulk',
    ++                    title => loc('Bulk Update'),
    ++                    path => '/Asset/Search/Bulk.html?'. $query_string->(%search),
    ++                );
    ++            }
    ++            $page->child('csv',
    ++                title => loc('Download Spreadsheet'),
    ++                path  => '/Asset/Search/Results.tsv?' . $query_string->(%search),
    ++            );
    ++        }
    ++        elsif ($request_path =~ m{^/Admin/Global/CustomFields/Catalog-Assets\.html$}) {
    ++            $page->child("create", title => loc("Create New"), path => "/Admin/CustomFields/Modify.html?Create=1;LookupType=" . RT::Asset->CustomFieldLookupType);
    ++        }
    ++        elsif ($request_path =~ m{^/Admin/CustomFields(/|/index\.html)?$}
    ++               and $DECODED_ARGS->{'Type'} and $DECODED_ARGS->{'Type'} eq RT::Asset->CustomFieldLookupType) {
    ++            $page->child("create")->path( $page->child("create")->path . "&LookupType=" . RT::Asset->CustomFieldLookupType );
    ++        }
    ++        elsif ($request_path =~ m{^/Admin/Assets/Catalogs/}) {
    ++            my $actions = $request_path =~ m{/((index|Create)\.html)?$}
    ++                ? $page
    ++                : $page->child("catalogs", title => loc("Catalogs"), path => "/Admin/Assets/Catalogs/");
    ++
    ++            $actions->child("select", title => loc("Select"), path => "/Admin/Assets/Catalogs/");
    ++            $actions->child("create", title => loc("Create"), path => "/Admin/Assets/Catalogs/Create.html");
    ++
    ++            my $catalog = RT::Catalog->new( $session{CurrentUser} );
    ++            $catalog->Load($DECODED_ARGS->{id}) if $DECODED_ARGS->{id};
    ++
    ++            if ($catalog->id and $catalog->CurrentUserCanSee) {
    ++                my $query = "id=" . $catalog->id;
    ++                $page->child("modify", title => loc("Basics"), path => "/Admin/Assets/Catalogs/Modify.html?$query");
    ++                $page->child("people", title => loc("Roles"),  path => "/Admin/Assets/Catalogs/Roles.html?$query");
    ++
    ++                $page->child("cfs", title => loc("Asset Custom Fields"), path => "/Admin/Assets/Catalogs/CustomFields.html?$query");
    ++
    ++                $page->child("group-rights", title => loc("Group Rights"), path => "/Admin/Assets/Catalogs/GroupRights.html?$query");
    ++                $page->child("user-rights",  title => loc("User Rights"),  path => "/Admin/Assets/Catalogs/UserRights.html?$query");
    ++            }
    ++        }
    ++    } ### END ASSETS MENU
    ++
    +     $m->callback( CallbackName => 'Privileged', Path => $request_path );
    + };
    + 
    +@@
    + 
    +     PageWidgets->child( goto => raw_html => $m->scomp('/SelfService/Elements/GotoTicket') );
    + 
    ++    { ### START ASSETS SELFSERVICE MENU
    ++        Menu->child("tickets")->add_after(
    ++            "assets",
    ++            title   => loc("Assets"),
    ++            path    => "/SelfService/Asset/",
    ++        );
    ++
    ++        # Page menus
    ++        my $page    = PageMenu();
    ++
    ++        if ($request_path =~ m{^/SelfService/Asset/} and $DECODED_ARGS->{id}) {
    ++            my $id = $DECODED_ARGS->{id};
    ++            $page->child("display",     title => loc("Display"),        path => "/SelfService/Asset/Display.html?id=$id");
    ++            $page->child("history",     title => loc("History"),        path => "/SelfService/Asset/History.html?id=$id");
    ++
    ++            if (Menu->child("new")) {
    ++                my $actions = $page->child("actions", title => loc("Actions"));
    ++                $actions->child("create-linked-ticket", title => loc("Create linked ticket"), path => "/SelfService/Asset/CreateLinkedTicket.html?Asset=$id");
    ++            }
    ++        }
    ++    } ### END ASSETS SELFSERVICE MENU
    ++
    +     $m->callback( CallbackName => 'SelfService', Path => $request_path );
    + };
    + 
    +
     diff --git a/share/html/SelfService/Asset/CreateLinkedTicket.html b/share/html/SelfService/Asset/CreateLinkedTicket.html
     new file mode 100644
     --- /dev/null
    @@ -4885,6 +8862,33 @@
     +%# END BPS TAGGED BLOCK }}}
     +<& /SelfService/Elements/Header, Title => loc("My Assets") &>
     +<& /User/Elements/AssetList, User => $session{'CurrentUser'}->UserObj, Roles => [''], Title => loc('My Assets') &>
    +
    +diff --git a/share/html/SelfService/Display.html b/share/html/SelfService/Display.html
    +--- a/share/html/SelfService/Display.html
    ++++ b/share/html/SelfService/Display.html
    +@@
    + </tr>
    + </table>
    + 
    ++<& /Ticket/Elements/ShowAssets, Ticket => $Ticket &>
    ++
    + % $m->callback(CallbackName => 'BeforeShowHistory', ARGSRef=> \%ARGS, Ticket => $Ticket );
    + 
    + <& /Elements/ShowHistory,
    +
    +diff --git a/share/html/Ticket/Create.html b/share/html/Ticket/Create.html
    +--- a/share/html/Ticket/Create.html
    ++++ b/share/html/Ticket/Create.html
    +@@
    +       <& /Ticket/Elements/EditTransactionCustomFields, %ARGS, QueueObj => $QueueObj, InTable => 1, KeepValue => 1, &>
    +     </table>
    +   </&>
    ++
    ++<& /Ticket/Elements/ShowAssetsOnCreate, QueueObj => $QueueObj, ARGSRef => \%ARGS &>
    ++
    + % $m->callback( CallbackName => 'AfterBasics', QueueObj => $QueueObj, ARGSRef => \%ARGS );
    + 
    + <& /Elements/EditCustomFieldCustomGroupings,
     
     diff --git a/share/html/Ticket/Elements/ShowAssets b/share/html/Ticket/Elements/ShowAssets
     new file mode 100644
    @@ -5223,6 +9227,18 @@
     +% $m->callback( CallbackName => "End", Queue => $QueueObj, Assets => $assets );
     +</&>
     
    +diff --git a/share/html/Ticket/Elements/ShowSummary b/share/html/Ticket/Elements/ShowSummary
    +--- a/share/html/Ticket/Elements/ShowSummary
    ++++ b/share/html/Ticket/Elements/ShowSummary
    +@@
    + % $m->callback( %ARGS, CallbackName => 'AfterDates' );
    + % my (@extra);
    + % push @extra, titleright_raw => '<a href="'. RT->Config->Get('WebPath'). '/Ticket/Graphs/index.html?id='.$Ticket->id.'">'.loc('Graph').'</a>' unless RT->Config->Get('DisableGraphViz');
    ++<& /Ticket/Elements/ShowAssets, Ticket => $Ticket &>
    + % $m->callback( %ARGS, CallbackName => 'LinksExtra', extra => \@extra );
    +     <&| /Widgets/TitleBox, title => loc('Links'),
    +         ($can_modify ? (title_href => RT->Config->Get('WebPath')."/Ticket/ModifyLinks.html?id=".$Ticket->Id) : ()),
    +
     diff --git a/share/html/User/Elements/AssetList b/share/html/User/Elements/AssetList
     new file mode 100644
     --- /dev/null
    @@ -5365,3 +9381,994 @@
     +$User
     +</%ARGS>
     
    +diff --git a/share/static/css/base/assets.css b/share/static/css/base/assets.css
    +new file mode 100644
    +--- /dev/null
    ++++ b/share/static/css/base/assets.css
    +@@
    ++#assets-accordion h3 {
    ++    position: relative;
    ++}
    ++
    ++#assets-accordion h3 a.unlink-asset {
    ++    position: absolute;
    ++    top: 0;
    ++    right: 0;
    ++    left: inherit;
    ++    padding: 0;
    ++}
    ++
    ++.ticket-assets .add-asset {
    ++    padding: 2em 0 0 0;
    ++    text-align: right;
    ++}
    ++
    ++body#comp-Asset-Search .collection-as-table td {
    ++    white-space: nowrap;
    ++}
    ++
    ++/* Colors */
    ++
    ++.asset-basics   .titlebox .titlebox-title .left,
    ++.asset-info-cfs .titlebox .titlebox-title .left { background-color: #b32    }
    ++.asset-people   .titlebox .titlebox-title .left { background-color: #48c    }
    ++.asset-dates    .titlebox .titlebox-title .left { background-color: #633063 }
    ++.asset-links    .titlebox .titlebox-title .left { background-color: #316531 }
    ++.ticket-assets  .titlebox .titlebox-title .left { background-color: #316531 }
    ++
    ++/* People display */
    ++
    ++#comp-Asset-Display .asset-people table {
    ++    width: 100%;
    ++}
    ++
    ++#comp-Asset-Display .asset-people td.label {
    ++    text-align: left;
    ++}
    ++
    ++#comp-Asset-Display .asset-people h3 {
    ++    margin: 0;
    ++    padding: 0;
    ++    line-height: 1.3;
    ++    font-size: 100%;
    ++}
    ++
    ++#comp-Asset-Display .asset-people .details {
    ++    padding: 0;
    ++}
    ++
    ++/* People editing */
    ++
    ++.asset-people .edit ul.role-members {
    ++    margin-top: 0;
    ++    padding-left: 0;
    ++    list-style: none;
    ++}
    ++
    ++.asset-people .edit td {
    ++    vertical-align: top;
    ++}
    ++
    ++.asset-people .edit .note {
    ++    font-size: 0.9em;
    ++}
    ++
    ++.asset-people .edit h3 {
    ++    margin-top: 0;
    ++    margin-bottom: 0;
    ++}
    ++
    ++.asset-people .edit .role h3 {
    ++    margin-bottom: 0.5em;
    ++}
    ++
    ++.asset-people .edit .role,
    ++.asset-people .edit .add-group,
    ++.asset-people .edit .add-user {
    ++    margin-bottom: 1em;
    ++}
    ++
    ++/* Asset summary */
    ++
    ++.ticket-assets form {
    ++    display: inherit;
    ++}
    ++
    ++.ticket-assets .related-tickets {
    ++    margin-top: 1em;
    ++}
    ++
    ++.ticket-assets .related-tickets .label a {
    ++    font-weight: bold;
    ++    color: black;
    ++}
    ++
    ++.asset-metadata>div {
    ++    vertical-align: top;
    ++    min-width: 30%;
    ++    max-width: 30%;
    ++    padding-right: 1.5em;
    ++    display: inline-block;
    ++}
    ++
    ++.asset-metadata {
    ++    padding-top: 2em; /* nav overflows this :( */;
    ++}
    ++
    ++ at media (max-width: 800px) {
    ++    .asset-metadata>div {
    ++        min-width: 45%;
    ++        width: 45%;
    ++    }
    ++}
    ++
    ++/* on a little screen, let's just use a single column */
    ++ at media (max-width: 600px) {
    ++    .asset-metadata {
    ++        padding-top: 6em;
    ++        /* nav overflows this: < */;
    ++    }
    ++
    ++    .asset-metadata>div {
    ++        min-width: 100%;
    ++        width: 100%;
    ++    }
    ++
    ++    #Asset-Create-basics>table,
    ++    #Asset-Create-basics>table>tbody>tr,
    ++    #Asset-Create-basics>table>tbody>tr>td {
    ++        display: block;
    ++    }
    ++}
    ++
    ++/* On a reasonable-width screen, make better use of whitespace */
    ++ at media (min-width: 601px) {
    ++    .asset-info-cfs .edit-custom-fields {
    ++        width: 100%;
    ++    }
    ++
    ++    .asset-info-cfs .edit-custom-fields tr td,
    ++    #ModifyAsset .asset-basics tr td {
    ++        display: inline-block;
    ++    }
    ++
    ++    .asset-info-cfs .edit-custom-fields tr,
    ++    #ModifyAsset .asset-basics tr {
    ++        display: inline-block;
    ++        width: 49%;
    ++    }
    ++
    ++    .asset-info-cfs .edit-custom-fields tr td.cflabel,
    ++    #ModifyAsset .asset-basics tr td.label,
    ++    #ModifyAsset .asset-basics tr td.cflabel {
    ++        width: 8em;
    ++    }
    ++
    ++    /* Asset creation */
    ++    #Asset-Create-basics>table {
    ++        width: 100%;
    ++        align: left;
    ++    }
    ++
    ++    #Asset-Create-basics>table>tbody>tr>td {
    ++        padding-right: 2em;
    ++    }
    ++
    ++    #Asset-Create-basics>table>tbody>tr {
    ++        vertical-align: top;
    ++    }
    ++}
    ++
    ++/* basic cleanups for the search UI's elements */
    ++.asset-search-grouping input.datepicker {
    ++    width: 7em;
    ++}
    ++
    ++.asset-search-grouping td * {
    ++    max-width: 11em;
    ++}
    ++
    ++.asset-search-grouping td.label.not {
    ++    min-width: 3em;
    ++    width: auto;
    ++    padding-left: 1em;
    ++}
    ++
    ++/* On a wide screen, use two columns for search/bulk criteria */
    ++ at media (min-width:1150px) {
    ++    .asset-bulk-grouping.asset-bulk-cfs,
    ++    .asset-search-grouping.asset-search-cfs {
    ++        display: inline-block;
    ++        width: 45%;
    ++        padding-right: 1em;
    ++        vertical-align: top;
    ++    }
    ++
    ++    .titlebox.asset-bulk-grouping.asset-bulk-cfs,
    ++    .titlebox.asset-search-grouping.asset-search-cfs {
    ++        display: block;
    ++        width: auto;
    ++        padding: inherit;
    ++    }
    ++
    ++    .asset-bulk-people tr.full-width,
    ++    .asset-search-people tr.ful-width,
    ++    .asset-bulk-basics tr.full-width,
    ++    .asset-search-basics tr.full-width {
    ++        width: 100%;
    ++    }
    ++
    ++    .asset-bulk-people tr,
    ++    .asset-search-people tr,
    ++    .asset-bulk-basics tr,
    ++    .asset-search-basics tr {
    ++        width: 49%;
    ++        display: inline-block;
    ++        white-space: nowrap;
    ++    }
    ++
    ++    .asset-bulk-people tr>td,
    ++    .asset-search-people tr>td,
    ++    .asset-bulk-basics tr>td,
    ++    .asset-search-basics tr>td {
    ++        display: inline-block;
    ++        width: 10em;
    ++    }
    ++}
    +
    +diff --git a/share/static/js/assets.js b/share/static/js/assets.js
    +new file mode 100644
    +--- /dev/null
    ++++ b/share/static/js/assets.js
    +@@
    ++jQuery(function() {
    ++    var showModal = function(html) {
    ++        jQuery("<div class='modal'></div>")
    ++            .append(html).appendTo("body")
    ++            .bind('modal:close', function(ev,modal) { modal.elm.remove(); })
    ++            .modal();
    ++    };
    ++
    ++    var assets = jQuery("#assets-accordion");
    ++    assets.accordion({
    ++        // Open the accordion if there's only one fold, otherwise start with
    ++        // all assets collapsed.
    ++        active:         assets.find("h3").length == 1 ? 0 : false,
    ++        collapsible:    true,
    ++        heightStyle:    'content',
    ++        header: "h3"
    ++    }).find("h3 a.unlink-asset").click(function(ev){
    ++        ev.stopPropagation();
    ++        return true;
    ++    });
    ++    jQuery(".ticket-assets form").submit(function(){
    ++        var input = jQuery("[name*=RefersTo]", this);
    ++        if (input.val())
    ++            input.val(input.val().match(/\S+/g)
    ++                                 .map(function(x){return "asset:"+x})
    ++                                 .join(" "));
    ++    });
    ++    jQuery("#page-actions-create-linked-ticket").click(function(ev){
    ++        ev.preventDefault();
    ++        var url = this.href.replace(/\/Asset\/CreateLinkedTicket\.html\?/g,
    ++                                    '/Asset/Helpers/CreateLinkedTicket?');
    ++        jQuery.get(
    ++            url,
    ++            showModal
    ++        );
    ++    });
    ++    jQuery("#assets-create").click(function(ev){
    ++        ev.preventDefault();
    ++        jQuery.get(
    ++            RT.Config.WebHomePath + "/Asset/Helpers/CreateInCatalog",
    ++            showModal
    ++        );
    ++    });
    ++});
    +
    +diff --git a/t/assets/api.t b/t/assets/api.t
    +new file mode 100644
    +--- /dev/null
    ++++ b/t/assets/api.t
    +@@
    ++use strict;
    ++use warnings;
    ++
    ++use RT::Test::Assets tests => undef;
    ++use Test::Warn;
    ++
    ++my $catalog;
    ++
    ++diag "Create a catalog";
    ++{
    ++    $catalog = create_catalog( Name => 'Test Catalog', Disabled => 1 );
    ++    ok $catalog && $catalog->id, "Created catalog";
    ++    is $catalog->Name, "Test Catalog", "Name is correct";
    ++    ok $catalog->Disabled, "Disabled";
    ++
    ++    my $asset;
    ++    warning_like {
    ++        $asset = create_asset( Name => "Test", Catalog => $catalog->id );
    ++    } qr/^Failed to create asset .* Invalid catalog/i;
    ++    ok !$asset, "Couldn't create asset in disabled catalog";
    ++
    ++    my ($ok, $msg) = $catalog->SetDisabled(0);
    ++    ok $ok, "Enabled catalog: $msg";
    ++    ok !$catalog->Disabled, "Enabled";
    ++}
    ++
    ++diag "Create basic asset (no CFs)";
    ++{
    ++    my $asset = RT::Asset->new( RT->SystemUser );
    ++    my ($id, $msg) = $asset->Create(
    ++        Name        => 'Thinkpad T420s',
    ++        Description => 'Laptop',
    ++        Catalog     => $catalog->Name,
    ++    );
    ++    ok $id, "Created: $msg";
    ++    is $asset->id, $id, "id matches";
    ++    is $asset->Name, "Thinkpad T420s", "Name matches";
    ++    is $asset->Description, "Laptop", "Description matches";
    ++
    ++    # Create txn
    ++    my @txns = @{$asset->Transactions->ItemsArrayRef};
    ++    is scalar @txns, 1, "One transaction";
    ++    is $txns[0]->Type, "Create", "... of type Create";
    ++
    ++    # Update
    ++    my ($txnid, $txnmsg) = $asset->SetName("Lenovo Thinkpad T420s");
    ++    ok $txnid, "Updated Name: $txnmsg";
    ++    is $asset->Name, "Lenovo Thinkpad T420s", "New Name matches";
    ++
    ++    # Set txn
    ++    @txns = @{$asset->Transactions->ItemsArrayRef};
    ++    is scalar @txns, 2, "Two transactions";
    ++    is $txns[1]->Type, "Set", "... the second of which is Set";
    ++    is $txns[1]->Field, "Name", "... Field is Name";
    ++    is $txns[1]->OldValue, "Thinkpad T420s", "... OldValue is correct";
    ++
    ++    # Delete
    ++    my ($ok, $err) = $asset->Delete;
    ++    ok !$ok, "Deletes are prevented: $err";
    ++    $asset->Load($id);
    ++    ok $asset->id, "Asset not deleted";
    ++}
    ++
    ++diag "Create with CFs";
    ++{
    ++    my $height = create_cf( Name => 'Height' );
    ++    ok $height->id, "Created CF";
    ++
    ++    my $material = create_cf( Name => 'Material' );
    ++    ok $material->id, "Created CF";
    ++
    ++    ok apply_cfs($height, $material), "Applied CFs";
    ++
    ++    my $asset = RT::Asset->new( RT->SystemUser );
    ++    my ($id, $msg) = $asset->Create(
    ++        Name                        => 'Standing desk',
    ++        "CustomField-".$height->id  => '46"',
    ++        "CustomField-Material"      => 'pine',
    ++        Catalog                     => $catalog->Name,
    ++    );
    ++    ok $id, "Created: $msg";
    ++    is $asset->FirstCustomFieldValue('Height'), '46"', "Found height";
    ++    is $asset->FirstCustomFieldValue('Material'), 'pine', "Found material";
    ++    is $asset->Transactions->Count, 1, "Only a single txn";
    ++}
    ++
    ++note "Create/update with Roles";
    ++{
    ++    my $root = RT::User->new( RT->SystemUser );
    ++    $root->Load("root");
    ++    ok $root->id, "Found root";
    ++
    ++    my $bps = RT::Test->load_or_create_user( Name => "BPS" );
    ++    ok $bps->id, "Created BPS user";
    ++
    ++    my $asset = RT::Asset->new( RT->SystemUser );
    ++    my ($id, $msg) = $asset->Create(
    ++        Name    => 'RT server',
    ++        HeldBy  => $root->PrincipalId,
    ++        Owner   => $bps->PrincipalId,
    ++        Contact => $bps->PrincipalId,
    ++        Catalog => $catalog->id,
    ++    );
    ++    ok $id, "Created: $msg";
    ++    is $asset->HeldBy->UserMembersObj->First->Name, "root", "root is Holder";
    ++    is $asset->Owner->Name, "BPS", "BPS is Owner";
    ++    is $asset->Contacts->UserMembersObj->First->Name, "BPS", "BPS is Contact";
    ++
    ++    my $sysadmins = RT::Group->new( RT->SystemUser );
    ++    $sysadmins->CreateUserDefinedGroup( Name => 'Sysadmins' );
    ++    ok $sysadmins->id, "Created group";
    ++    is $sysadmins->Name, "Sysadmins", "Got group name";
    ++
    ++    (my $ok, $msg) = $asset->AddRoleMember(
    ++        Type        => 'Contact',
    ++        Group       => 'Sysadmins',
    ++    );
    ++    ok $ok, "Added Sysadmins as Contact: $msg";
    ++    is $asset->Contacts->MembersObj->Count, 2, "Found two members";
    ++
    ++    my @txn = grep { $_->Type eq 'AddWatcher' } @{$asset->Transactions->ItemsArrayRef};
    ++    ok @txn == 1, "Found one AddWatcher txn";
    ++    is $txn[0]->Field, "Contact", "... of a Contact";
    ++    is $txn[0]->NewValue, $sysadmins->PrincipalId, "... for the right principal";
    ++
    ++    ($ok, $msg) = $asset->DeleteRoleMember(
    ++        Type        => 'Contact',
    ++        PrincipalId => $bps->PrincipalId,
    ++    );
    ++    ok $ok, "Removed BPS user as Contact: $msg";
    ++    is $asset->Contacts->MembersObj->Count, 1, "Now just one member";
    ++    is $asset->Contacts->GroupMembersObj(Recursively => 0)->First->Name, "Sysadmins", "... it's Sysadmins";
    ++
    ++    @txn = grep { $_->Type eq 'DelWatcher' } @{$asset->Transactions->ItemsArrayRef};
    ++    ok @txn == 1, "Found one DelWatcher txn";
    ++    is $txn[0]->Field, "Contact", "... of a Contact";
    ++    is $txn[0]->OldValue, $bps->PrincipalId, "... for the right principal";
    ++}
    ++
    ++diag "Custom Field handling";
    ++{
    ++    diag "Make sure we don't load queue CFs";
    ++    my $queue_cf = RT::CustomField->new( RT->SystemUser );
    ++    my ($ok, $msg) = $queue_cf->Create(
    ++        Name       => "Queue CF",
    ++        Type       => "Text",
    ++        LookupType => RT::Queue->CustomFieldLookupType,
    ++    );
    ++    ok( $queue_cf->Id, "Created test CF: " . $queue_cf->Id);
    ++
    ++    my $cf1 = RT::CustomField->new( RT->SystemUser );
    ++    $cf1->LoadByNameAndCatalog ( Name => "Queue CF" );
    ++
    ++    ok( (not $cf1->Id), "Queue CF not loaded with LoadByNameAndCatalog");
    ++
    ++    my $cf2 = RT::CustomField->new( RT->SystemUser );
    ++    $cf2->LoadByNameAndCatalog ( Name => "Height" );
    ++    ok( $cf2->Id, "Loaded CF id: " . $cf2->Id . " with name");
    ++    ok( $cf2->Name, "Loaded CF name: " . $cf2->Name . " with name");
    ++
    ++    my $cf3 = RT::CustomField->new( RT->SystemUser );
    ++    ($ok, $msg) = $cf3->LoadByNameAndCatalog ( Name => "Height", Catalog => $catalog->Name );
    ++    ok( (not $cf3->Id), "CF 'Height'"
    ++      . " not added to catalog: " . $catalog->Name);
    ++
    ++    my $color = create_cf( Name => 'Color'  );
    ++    ok $color->Id, "Created CF " . $color->Name;
    ++    ($ok, $msg) = $color->AddToObject( $catalog );
    ++
    ++    ($ok, $msg) = $color->LoadByNameAndCatalog ( Name => "Color", Catalog => $catalog->Name );
    ++    ok( $color->Id, "Loaded CF id: " . $color->Id
    ++      . " for catalog: " . $catalog->Name);
    ++    ok( $color->Name, "Loaded CF name: " . $color->Name
    ++    . " for catalog: " . $catalog->Name);
    ++
    ++}
    ++
    ++
    ++done_testing;
    +
    +diff --git a/t/assets/collection.t b/t/assets/collection.t
    +new file mode 100644
    +--- /dev/null
    ++++ b/t/assets/collection.t
    +@@
    ++use strict;
    ++use warnings;
    ++
    ++use RT::Test::Assets tests => undef;
    ++
    ++my $user = RT::Test->load_or_create_user( Name => 'testuser' );
    ++ok $user->id, "Created user";
    ++
    ++my $catalog  = create_catalog( Name => "BPS" );
    ++ok $catalog && $catalog->id, "Created catalog";
    ++
    ++my $location = create_cf( Name => 'Location' );
    ++ok $location->id, "Created CF";
    ++ok apply_cfs($location), "Applied CF";
    ++
    ++ok(
    ++    create_assets(
    ++        { Name => "Thinkpad T420s", Catalog => $catalog->id, "CustomField-Location" => "Home" },
    ++        { Name => "Standing desk",  Catalog => $catalog->id, "CustomField-Location" => "Office" },
    ++        { Name => "Chair",          Catalog => $catalog->id, "CustomField-Location" => "Office" },
    ++    ),
    ++    "Created assets"
    ++);
    ++
    ++diag "Mark chair as deleted";
    ++{
    ++    my $asset = RT::Asset->new( RT->SystemUser );
    ++    $asset->LoadByCols( Name => "Chair" );
    ++    my ($ok, $msg) = $asset->SetStatus( "deleted" );
    ++    ok($ok, "Deleted the chair: $msg");
    ++}
    ++
    ++diag "Basic types of limits";
    ++{
    ++    my $assets = RT::Assets->new( RT->SystemUser );
    ++    $assets->Limit( FIELD => 'Name', OPERATOR => 'LIKE', VALUE => 'thinkpad' );
    ++    is $assets->Count, 1, "Found 1 like thinkpad";
    ++    is $assets->First->Name, "Thinkpad T420s";
    ++
    ++    $assets = RT::Assets->new( RT->SystemUser );
    ++    $assets->UnLimit;
    ++    is $assets->Count, 2, "Found 2 total";
    ++    ok((!grep { $_->Name eq "Chair" } @{$assets->ItemsArrayRef}), "No chair (disabled)");
    ++
    ++    $assets = RT::Assets->new( RT->SystemUser );
    ++    $assets->Limit( FIELD => 'Status', VALUE => 'deleted' );
    ++    $assets->{allow_deleted_search} = 1;
    ++    is $assets->Count, 1, "Found 1 deleted";
    ++    is $assets->First->Name, "Chair", "Found chair";
    ++
    ++    $assets = RT::Assets->new( RT->SystemUser );
    ++    $assets->UnLimit;
    ++    $assets->LimitCustomField(
    ++        CUSTOMFIELD => $location->id,
    ++        VALUE       => "Office",
    ++    );
    ++    is $assets->Count, 1, "Found 1 in Office";
    ++    ok $assets->First, "Got record";
    ++    is $assets->First->Name, "Standing desk", "Found standing desk";
    ++}
    ++
    ++diag "Test ACLs";
    ++{
    ++    my $assets = RT::Assets->new( RT::CurrentUser->new($user) );
    ++    $assets->UnLimit;
    ++    is scalar @{$assets->ItemsArrayRef}, 0, "Found none";
    ++}
    ++
    ++done_testing;
    +
    +diff --git a/t/assets/compile.t b/t/assets/compile.t
    +new file mode 100644
    +--- /dev/null
    ++++ b/t/assets/compile.t
    +@@
    ++use strict;
    ++use warnings;
    ++
    ++use Test::More;
    ++
    ++use_ok('RT::Test::Assets');
    ++use_ok('RT::Asset');
    ++use_ok('RT::Assets');
    ++use_ok('RT::Catalog');
    ++use_ok('RT::Catalogs');
    +
    +diff --git a/t/assets/links.t b/t/assets/links.t
    +new file mode 100644
    +--- /dev/null
    ++++ b/t/assets/links.t
    +@@
    ++use strict;
    ++use warnings;
    ++
    ++use RT::Test::Assets tests => undef;
    ++use Test::Warn;
    ++
    ++my $catalog = create_catalog( Name => "BPS" );
    ++ok $catalog && $catalog->id, "Created Catalog";
    ++
    ++ok(
    ++    create_assets(
    ++        { Name => "Thinkpad T420s", Catalog => $catalog->id },
    ++        { Name => "Standing desk", Catalog => $catalog->id },
    ++        { Name => "Chair", Catalog => $catalog->id },
    ++    ),
    ++    "Created assets"
    ++);
    ++
    ++my $ticket = RT::Test->create_ticket(
    ++    Queue   => 1,
    ++    Subject => 'a test ticket',
    ++);
    ++ok $ticket->id, "Created ticket";
    ++
    ++diag "RT::URI::asset";
    ++{
    ++    my %uris = (
    ++        # URI                   => Asset Name
    ++        "asset:1"               => { id => 1, Name => "Thinkpad T420s" },
    ++        "asset://example.com/2" => { id => 2, Name => "Standing desk" },
    ++        "asset:13"              => undef,
    ++    );
    ++
    ++    while (my ($url, $expected) = each %uris) {
    ++        my $uri = RT::URI->new( RT->SystemUser );
    ++        if ($expected) {
    ++            my $parsed = $uri->FromURI($url);
    ++            ok $parsed, "Parsed $url";
    ++
    ++            my $asset = $uri->Object;
    ++            ok $asset, "Got object";
    ++            is ref($asset), "RT::Asset", "... it's a RT::Asset";
    ++
    ++            while (my ($field, $value) = each %$expected) {
    ++                is $asset->$field, $value, "... $field is $value";
    ++            }
    ++        } else {
    ++            my $parsed;
    ++            warnings_like {
    ++                $parsed = $uri->FromURI($url);
    ++            } [qr/\Q$url\E/, qr/\Q$url\E/], "Caught warnings about unknown URI";
    ++            ok !$parsed, "Failed to parse $url, as expected";
    ++        }
    ++    }
    ++}
    ++
    ++diag "RT::Asset link support";
    ++{
    ++    my $chair = RT::Asset->new( RT->SystemUser );
    ++    $chair->LoadByCols( Name => "Chair" );
    ++    ok $chair->id, "Loaded asset";
    ++    is $chair->URI, "asset://example.com/".$chair->id, "->URI works";
    ++
    ++    my ($link_id, $msg) = $chair->AddLink( Type => 'MemberOf', Target => 'asset:2' );
    ++    ok $link_id, "Added link: $msg";
    ++
    ++    my $parents = $chair->MemberOf;
    ++    my $desk    = $parents->First->TargetObj;
    ++    is $parents->Count, 1, "1 parent";
    ++    is $desk->Name, "Standing desk", "Correct parent asset";
    ++
    ++    for my $asset ($chair, $desk) {
    ++        my $txns = $asset->Transactions;
    ++        $txns->Limit( FIELD => 'Type', VALUE => 'AddLink' );
    ++        is $txns->Count, 1, "1 AddLink txn on asset ".$asset->Name;
    ++    }
    ++
    ++    my ($ok, $err) = $chair->DeleteLink( Type => 'MemberOf', Target => 'asset:1' );
    ++    ok !$ok, "Delete link failed on non-existent: $err";
    ++
    ++    my ($deleted, $delete_msg) = $chair->DeleteLink( Type => 'MemberOf', Target => $parents->First->Target );
    ++    ok $deleted, "Deleted link: $delete_msg";
    ++
    ++    for my $asset ($chair, $desk) {
    ++        my $txns = $asset->Transactions;
    ++        $txns->Limit( FIELD => 'Type', VALUE => 'DeleteLink' );
    ++        is $txns->Count, 1, "1 DeleteLink txn on asset ".$asset->Name;
    ++    }
    ++};
    ++
    ++diag "Linking to tickets";
    ++{
    ++    my $laptop = RT::Asset->new( RT->SystemUser );
    ++    $laptop->LoadByCols( Name => "Thinkpad T420s" );
    ++
    ++    my ($ok, $msg) = $ticket->AddLink( Type => 'RefersTo', Target => $laptop->URI );
    ++    ok $ok, "Ticket refers to asset: $msg";
    ++
    ++    my $links = $laptop->ReferredToBy;
    ++    is $links->Count, 1, "Found a ReferredToBy link via asset";
    ++
    ++    ($ok, $msg) = $laptop->DeleteLink( Type => 'RefersTo', Base => $ticket->URI );
    ++    ok $ok, "Deleted link from opposite side: $msg";
    ++}
    ++
    ++diag "Links on ->Create";
    ++{
    ++    my $desk = RT::Asset->new( RT->SystemUser );
    ++    $desk->LoadByCols( Name => "Standing desk" );
    ++    ok $desk->id, "Loaded standing desk asset";
    ++
    ++    my $asset = create_asset(
    ++        Name            => "Anti-fatigue mat",
    ++        Catalog         => $catalog->id,
    ++        Parent          => $desk->URI,
    ++        ReferredToBy    => [$ticket->id],
    ++    );
    ++    ok $asset->id, "Created asset with Parent link";
    ++
    ++    my $parents = $asset->MemberOf;
    ++    is $parents->Count, 1, "Found one Parent";
    ++    is $parents->First->Target, $desk->URI, "... it's a desk!";
    ++
    ++    my $referrals = $asset->ReferredToBy;
    ++    is $referrals->Count, 1, "Found one ReferredToBy";
    ++    is $referrals->First->Base, $ticket->URI, "... it's the ticket!";
    ++}
    ++
    ++done_testing;
    +
    +diff --git a/t/assets/pod.t b/t/assets/pod.t
    +new file mode 100644
    +--- /dev/null
    ++++ b/t/assets/pod.t
    +@@
    ++use strict;
    ++use warnings;
    ++
    ++use Test::More;
    ++use Test::Pod;
    ++all_pod_files_ok( all_pod_files("lib","doc","etc"));
    +
    +diff --git a/t/assets/rights.t b/t/assets/rights.t
    +new file mode 100644
    +--- /dev/null
    ++++ b/t/assets/rights.t
    +@@
    ++use strict;
    ++use warnings;
    ++
    ++use RT::Test::Assets tests => undef;
    ++
    ++my $user = RT::Test->load_or_create_user( Name => 'testuser' );
    ++ok $user->id, "Created user";
    ++
    ++my $ticket = RT::Test->create_ticket(
    ++    Queue   => 1,
    ++    Subject => 'a test ticket',
    ++);
    ++ok $ticket->id, "Created ticket";
    ++
    ++my $catalog_one = create_catalog( Name => "One" );
    ++ok $catalog_one && $catalog_one->id, "Created catalog one";
    ++
    ++my $catalog_two = create_catalog( Name => "Two" );
    ++ok $catalog_two && $catalog_two->id, "Created catalog two";
    ++
    ++ok(RT::Test->add_rights({
    ++    Principal   => 'Privileged',
    ++    Right       => 'ShowCatalog',
    ++    Object      => $catalog_one,
    ++}), "Granted ShowCatalog");
    ++
    ++my $asset = RT::Asset->new( RT::CurrentUser->new($user) );
    ++
    ++diag "CreateAsset";
    ++{
    ++    my %create = (
    ++        Name    => 'Thinkpad T420s',
    ++        Contact => 'trs at example.com',
    ++        Catalog => $catalog_one->id,
    ++    );
    ++    my ($id, $msg) = $asset->Create(%create);
    ++    ok !$id, "Create denied: $msg";
    ++
    ++    ok(RT::Test->add_rights({
    ++        Principal   => 'Privileged',
    ++        Right       => 'CreateAsset',
    ++        Object      => $catalog_one,
    ++    }), "Granted CreateAsset");
    ++
    ++    ($id, $msg) = $asset->Create(%create);
    ++    ok $id, "Created: $msg";
    ++    is $asset->id, $id, "id matches";
    ++    is $asset->CatalogObj->Name, $catalog_one->Name, "Catalog matches";
    ++};
    ++
    ++diag "ShowAsset";
    ++{
    ++    is $asset->Name, undef, "Can't see Name without ShowAsset";
    ++    ok !$asset->Contacts->id, "Can't see Contacts role group";
    ++
    ++    ok(RT::Test->add_rights({
    ++        Principal   => 'Privileged',
    ++        Right       => 'ShowAsset',
    ++        Object      => $catalog_one,
    ++    }), "Granted ShowAsset");
    ++
    ++    is $asset->Name, "Thinkpad T420s", "Got Name";
    ++    is $asset->Contacts->UserMembersObj->First->EmailAddress, 'trs at example.com', "Got Contact";
    ++}
    ++
    ++diag "ModifyAsset";
    ++{
    ++    my ($txnid, $txnmsg) = $asset->SetName("Lenovo Thinkpad T420s");
    ++    ok !$txnid, "Update failed: $txnmsg";
    ++    is $asset->Name, "Thinkpad T420s", "Name didn't change";
    ++
    ++    my ($ok, $msg) = $asset->AddLink( Type => 'RefersTo', Target => 't:1' );
    ++    ok !$ok, "No rights to AddLink: $msg";
    ++
    ++    ($ok, $msg) = $asset->DeleteLink( Type => 'RefersTo', Target => 't:1' );
    ++    ok !$ok, "No rights to DeleteLink: $msg";
    ++
    ++    ok(RT::Test->add_rights({
    ++        Principal   => 'Privileged',
    ++        Right       => 'ModifyAsset',
    ++        Object      => $catalog_one,
    ++    }), "Granted ModifyAsset");
    ++    
    ++    ($txnid, $txnmsg) = $asset->SetName("Lenovo Thinkpad T420s");
    ++    ok $txnid, "Updated Name: $txnmsg";
    ++    is $asset->Name, "Lenovo Thinkpad T420s", "Name changed";
    ++}
    ++
    ++diag "Catalogs";
    ++{
    ++    my ($txnid, $txnmsg) = $asset->SetCatalog($catalog_two->id);
    ++    ok !$txnid, "Failed to update Catalog: $txnmsg";
    ++    is $asset->CatalogObj->Name, $catalog_one->Name, "Catalog unchanged";
    ++
    ++    ok(RT::Test->add_rights({
    ++        Principal   => 'Privileged',
    ++        Right       => 'CreateAsset',
    ++        Object      => $catalog_two,
    ++    }), "Granted CreateAsset in second catalog");
    ++
    ++    ($txnid, $txnmsg) = $asset->SetCatalog($catalog_two->id);
    ++    ok $txnid, "Updated Catalog: $txnmsg";
    ++    unlike $txnmsg, qr/Permission Denied/i, "Transaction message isn't Permission Denied";
    ++    ok !$asset->CurrentUserCanSee, "Can no longer see the asset";
    ++
    ++    ok(RT::Test->add_rights({
    ++        Principal   => 'Privileged',
    ++        Right       => 'ShowAsset',
    ++        Object      => $catalog_two,
    ++    }), "Granted ShowAsset");
    ++
    ++    ok $asset->CurrentUserCanSee, "Can see the asset now";
    ++    is $asset->CatalogObj->Name, undef, "Can't see the catalog name still";
    ++
    ++    ok(RT::Test->add_rights({
    ++        Principal   => 'Privileged',
    ++        Right       => 'ShowCatalog',
    ++        Object      => $catalog_two,
    ++    }), "Granted ShowCatalog");
    ++
    ++    is $asset->CatalogObj->Name, $catalog_two->Name, "Now we can see the catalog name";
    ++}
    ++
    ++done_testing;
    +
    +diff --git a/t/assets/roles.t b/t/assets/roles.t
    +new file mode 100644
    +--- /dev/null
    ++++ b/t/assets/roles.t
    +@@
    ++use strict;
    ++use warnings;
    ++
    ++use RT::Test::Assets tests => undef;
    ++
    ++my $catalog = create_catalog( Name => "A catalog" );
    ++my $asset = create_asset( Name => "Test asset", Catalog => $catalog->id );
    ++ok $asset && $asset->id, "Created asset";
    ++
    ++for my $object ($asset, $catalog, RT->System) {
    ++    for my $role (RT::Asset->Roles) {
    ++        my $group = $object->RoleGroup($role);
    ++        ok $group->id, "Loaded role group $role for " . ref($object);
    ++
    ++        my $principal = $group->PrincipalObj;
    ++        ok $principal && $principal->id, "Found PrincipalObj for role group"
    ++            or next;
    ++
    ++        if ($object->DOES("RT::Record::Role::Rights")) {
    ++            my ($ok, $msg) = $principal->GrantRight(
    ++                Object  => $object,
    ++                Right   => "ShowAsset",
    ++            );
    ++            ok $ok, "Granted right" or diag "Error: $msg";
    ++        }
    ++    }
    ++}
    ++
    ++done_testing;
    +
    +diff --git a/t/assets/web.t b/t/assets/web.t
    +new file mode 100644
    +--- /dev/null
    ++++ b/t/assets/web.t
    +@@
    ++use strict;
    ++use warnings;
    ++
    ++use RT::Test::Assets tests => undef;
    ++
    ++RT->Config->Set("CustomFieldGroupings",
    ++    "RT::Asset" => {
    ++        Dates => [qw(Purchased)],
    ++    },
    ++);
    ++
    ++my $catalog = create_catalog( Name => "Office" );
    ++ok $catalog->id, "Created Catalog";
    ++
    ++my $purchased = create_cf( Name => 'Purchased', Pattern => '(?#Year)^(?:19|20)\d{2}$' );
    ++ok $purchased->id, "Created CF";
    ++
    ++my $height = create_cf( Name => 'Height', Pattern => '(?#Inches)^\d+"?$' );
    ++ok $height->id, "Created CF";
    ++
    ++my $material = create_cf( Name => 'Material' );
    ++ok $material->id, "Created CF";
    ++
    ++my %CF = (
    ++    Height      => ".CF-" . $height->id    . "-Edit",
    ++    Material    => ".CF-" . $material->id  . "-Edit",
    ++    Purchased   => ".CF-" . $purchased->id . "-Edit",
    ++);
    ++
    ++my ($base, $m) = RT::Test::Assets->started_ok;
    ++ok $m->login, "Logged in agent";
    ++
    ++diag "Create basic asset (no CFs)";
    ++{
    ++    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
    ++    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id } }, "Picked a catalog");
    ++    $m->submit_form_ok({
    ++        with_fields => {
    ++            id          => 'new',
    ++            Name        => 'Thinkpad T420s',
    ++            Description => 'A laptop',
    ++        },
    ++    }, "submited create form");
    ++    $m->content_like(qr/Asset .* created/, "Found created message");
    ++    my ($id) = $m->uri =~ /id=(\d+)/;
    ++
    ++    my $asset = RT::Asset->new( RT->SystemUser );
    ++    $asset->Load($id);
    ++    is $asset->id, $id, "id matches";
    ++    is $asset->Name, "Thinkpad T420s", "Name matches";
    ++    is $asset->Description, "A laptop", "Description matches";
    ++}
    ++
    ++diag "Create with CFs";
    ++{
    ++    ok apply_cfs($height, $material), "Applied CFs";
    ++
    ++    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
    ++    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id } }, "Picked a catalog");
    ++
    ++    ok $m->form_with_fields(qw(id Name Description)), "Found form";
    ++    $m->submit_form_ok({
    ++        fields => {
    ++            id              => 'new',
    ++            Name            => 'Standing desk',
    ++            $CF{Height}     => 'forty-six inches',
    ++            $CF{Material}   => 'pine',
    ++        },
    ++    }, "submited create form");
    ++    $m->content_unlike(qr/Asset .* created/, "Lacks created message");
    ++    $m->content_like(qr/must match .*?Inches/, "Found validation error");
    ++
    ++    # Intentionally fix only the invalid CF to test the other fields are
    ++    # preserved across errors
    ++    ok $m->form_with_fields(qw(id Name Description)), "Found form again";
    ++    $m->set_fields( $CF{Height} => '46"' );
    ++    $m->submit_form_ok({}, "resubmitted form");
    ++
    ++    $m->content_like(qr/Asset .* created/, "Found created message");
    ++    my ($id) = $m->uri =~ /id=(\d+)/;
    ++
    ++    my $asset = RT::Asset->new( RT->SystemUser );
    ++    $asset->Load($id);
    ++    is $asset->id, $id, "id matches";
    ++    is $asset->FirstCustomFieldValue('Height'), '46"', "Found height";
    ++    is $asset->FirstCustomFieldValue('Material'), 'pine', "Found material";
    ++}
    ++
    ++diag "Create with CFs in other groups";
    ++{
    ++    ok apply_cfs($purchased), "Applied CF";
    ++
    ++    $m->follow_link_ok({ id => "assets-create" }, "Asset create link");
    ++    $m->submit_form_ok({ with_fields => { Catalog => $catalog->id } }, "Picked a catalog");
    ++
    ++    ok $m->form_with_fields(qw(id Name Description)), "Found form";
    ++
    ++    $m->submit_form_ok({
    ++        fields => {
    ++            id          => 'new',
    ++            Name        => 'Chair',
    ++            $CF{Height} => '23',
    ++        },
    ++    }, "submited create form");
    ++
    ++    $m->content_like(qr/Asset .* created/, "Found created message");
    ++    $m->content_unlike(qr/Purchased.*?must match .*?Year/, "Lacks validation error for Purchased");
    ++}
    ++
    ++# XXX TODO: test other modify pages
    ++
    ++undef $m;
    ++done_testing;
8:  bc64c0a < -:  ------- css/js in the right place



More information about the rt-commit mailing list