[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