This is a squashed branch of all of the various commits that make up the new HasCustomFields trait.

This should allow us to add custom fields to just about anything we want to within Snipe-IT.

Below are the commits that have been squashed together:

Initial decoupling of custom field behavior from Assets for re-use

Add new DB columns to Custom Fields and fieldsets for 'type'

WIP: trying to figure out UI for custom fields for things other than Assets, find problematic places

Real progress towards getting to where this stuff might actually work...

Fix the table-name determining code for Custom Fields

Getting it closer to where Assets at least work

Rename the trait to it's new, even better name

Solid progress on the new Trait!

WIP: HasCustomFields, still working some stuff out

Got some basics working; creating custom fields and stuff

HasCustomFields now validates and saves

Starting to yank the other boilerplate code as things start to work (!)

Got the start of defaultValuesForCustomField() working

More progress (squash me!)

Add migrations for default_values_for_custom_fields table

WIP: more towards hasCustomFields trait

Progress cleaning up the PR, fixing FIXME's

New, passing HasCustomFieldsTrait test!

Fix date formatter helper for custom fields

Fixed more FIXME's
This commit is contained in:
Brady Wetherington 2024-06-06 13:35:38 +01:00
parent e19d2d6ec9
commit d2b7828569
36 changed files with 634 additions and 244 deletions

View file

@ -651,6 +651,17 @@ class Helper
return $customfields;
}
/**
* Get all of the different types of custom fields there are
* TODO - how to make this more general? Or more useful? or more dynamic?
* idea - key of classname, *value* of trans? (thus having to make this a method, which is fine)
*/
static $itemtypes_having_custom_fields = [
0 => \App\Models\Asset::class,
1 => \App\Models\User::class,
2 => \App\Models\Accessory::class
];
/**
* Get the list of custom field formats in an array to make a dropdown menu
*

View file

@ -67,7 +67,7 @@ class AssetModelsController extends Controller
'models.deleted_at',
'models.updated_at',
])
->with('category', 'depreciation', 'manufacturer', 'fieldset.fields.defaultValues')
->with('category', 'depreciation', 'manufacturer')
->withCount('assets as assets_count');
if ($request->input('status')=='deleted') {

View file

@ -120,7 +120,7 @@ class AssetsController extends Controller
$filter = json_decode($request->input('filter'), true);
}
$all_custom_fields = CustomField::all(); //used as a 'cache' of custom fields throughout this page load
$all_custom_fields = CustomField::where('type', Asset::class); //used as a 'cache' of custom fields throughout this page load
foreach ($all_custom_fields as $field) {
$allowed_columns[] = $field->db_column_name();
}
@ -586,48 +586,8 @@ class AssetsController extends Controller
$asset = $request->handleImages($asset);
// Update custom fields in the database.
$model = AssetModel::find($request->input('model_id'));
$asset->customFill($request, Auth::user(), true);
// Check that it's an object and not a collection
// (Sometimes people send arrays here and they shouldn't
if (($model) && ($model instanceof AssetModel) && ($model->fieldset)) {
foreach ($model->fieldset->fields as $field) {
// Set the field value based on what was sent in the request
$field_val = $request->input($field->db_column, null);
// If input value is null, use custom field's default value
if ($field_val == null) {
Log::debug('Field value for '.$field->db_column.' is null');
$field_val = $field->defaultValue($request->get('model_id'));
Log::debug('Use the default fieldset value of '.$field->defaultValue($request->get('model_id')));
}
// if the field is set to encrypted, make sure we encrypt the value
if ($field->field_encrypted == '1') {
Log::debug('This model field is encrypted in this fieldset.');
if (Gate::allows('admin')) {
// If input value is null, use custom field's default value
if (($field_val == null) && ($request->has('model_id') != '')) {
$field_val = Crypt::encrypt($field->defaultValue($request->get('model_id')));
} else {
$field_val = Crypt::encrypt($request->input($field->db_column));
}
}
}
if ($field->element == 'checkbox') {
if(is_array($field_val)) {
$field_val = implode(',', $field_val);
}
}
$asset->{$field->db_column} = $field_val;
}
}
if ($asset->save()) {
if ($request->get('assigned_user')) {
@ -689,32 +649,8 @@ class AssetsController extends Controller
$asset = $request->handleImages($asset);
$model = AssetModel::find($asset->model_id);
// Update custom fields
$problems_updating_encrypted_custom_fields = false;
if (($model) && (isset($model->fieldset))) {
foreach ($model->fieldset->fields as $field) {
$field_val = $request->input($field->db_column, null);
if ($request->has($field->db_column)) {
if ($field->element == 'checkbox') {
if(is_array($field_val)) {
$field_val = implode(',', $field_val);
}
}
if ($field->field_encrypted == '1') {
if (Gate::allows('admin')) {
$field_val = Crypt::encrypt($field_val);
} else {
$problems_updating_encrypted_custom_fields = true;
continue;
}
}
$asset->{$field->db_column} = $field_val;
}
}
}
$asset->customFill($request, Auth::user());
if ($asset->save()) {
if (($request->filled('assigned_user')) && ($target = User::find($request->get('assigned_user')))) {

View file

@ -35,7 +35,7 @@ class CustomFieldsetsController extends Controller
public function index()
{
$this->authorize('index', CustomField::class);
$fieldsets = CustomFieldset::withCount('fields as fields_count', 'models as models_count')->get();
$fieldsets = CustomFieldset::withCount('fields as fields_count')->get();
return (new CustomFieldsetsTransformer)->transformCustomFieldsets($fieldsets, $fieldsets->count());
}
@ -125,7 +125,7 @@ class CustomFieldsetsController extends Controller
$this->authorize('delete', CustomField::class);
$fieldset = CustomFieldset::findOrFail($id);
$modelsCount = $fieldset->models->count();
$modelsCount = $fieldset->customizables()->count();
$fieldsCount = $fieldset->fields->count();
if (($modelsCount > 0) || ($fieldsCount > 0)) {

View file

@ -9,6 +9,7 @@ use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\CustomField;
use App\Models\User;
use App\Models\DefaultValuesForCustomFields;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\View;
@ -163,7 +164,7 @@ class AssetModelsController extends Controller
$model->notes = $request->input('notes');
$model->requestable = $request->input('requestable', '0');
$this->removeCustomFieldsDefaultValues($model);
DefaultValuesForCustomFields::forPivot($model, Asset::class)->delete();
$model->fieldset_id = $request->input('fieldset_id');
@ -480,7 +481,7 @@ class AssetModelsController extends Controller
}
/**
* Adds default values to a model (as long as they are truthy)
* Adds default values to a model (as long as they are truthy) (does this mean I cannot set a default value of 0?)
*
* @param AssetModel $model
* @param array $defaultValues
@ -515,22 +516,12 @@ class AssetModelsController extends Controller
}
foreach ($defaultValues as $customFieldId => $defaultValue) {
if(is_array($defaultValue)){
$model->defaultValues()->attach($customFieldId, ['default_value' => implode(', ', $defaultValue)]);
}elseif ($defaultValue) {
$model->defaultValues()->attach($customFieldId, ['default_value' => $defaultValue]);
if (is_array($defaultValue)) {
$defaultValue = implode(', ', $defaultValue);
}
DefaultValuesForCustomFields::updateOrCreate(['custom_field_id' => $customFieldId, 'item_pivot_id' => $model->id], ['default_value' => $defaultValue]);
}
return true;
}
/**
* Removes all default values
*
* @return void
*/
private function removeCustomFieldsDefaultValues(AssetModel $model)
{
$model->defaultValues()->detach();
}
}

View file

@ -160,29 +160,7 @@ class AssetsController extends Controller
$asset = $request->handleImages($asset);
}
// Update custom fields in the database.
// Validation for these fields is handled through the AssetRequest form request
$model = AssetModel::find($request->get('model_id'));
if (($model) && ($model->fieldset)) {
foreach ($model->fieldset->fields as $field) {
if ($field->field_encrypted == '1') {
if (Gate::allows('admin')) {
if (is_array($request->input($field->db_column))) {
$asset->{$field->db_column} = Crypt::encrypt(implode(', ', $request->input($field->db_column)));
} else {
$asset->{$field->db_column} = Crypt::encrypt($request->input($field->db_column));
}
}
} else {
if (is_array($request->input($field->db_column))) {
$asset->{$field->db_column} = implode(', ', $request->input($field->db_column));
} else {
$asset->{$field->db_column} = $request->input($field->db_column);
}
}
}
}
$asset->customFill($request, Auth::user()); // Update custom fields in the database.
// Validate the asset before saving
if ($asset->isValid() && $asset->save()) {

View file

@ -9,6 +9,7 @@ use App\Models\CustomFieldset;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request;
/**
* This controller handles all actions related to Custom Asset Fields for
* the Snipe-IT Asset Management application.
@ -20,6 +21,7 @@ use Illuminate\Http\Request;
*/
class CustomFieldsController extends Controller
{
/**
* Returns a view with a listing of custom fields.
*
@ -28,12 +30,12 @@ class CustomFieldsController extends Controller
* @return \Illuminate\Support\Facades\View
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function index()
public function index(Request $request)
{
$this->authorize('view', CustomField::class);
$fieldsets = CustomFieldset::with('fields', 'models')->get();
$fields = CustomField::with('fieldset')->get();
$fieldsets = CustomFieldset::with('fields')->where("type", Helper::$itemtypes_having_custom_fields[$request->get('tab', 0)])->get(); //cannot eager-load 'customizable' because it's not a relation
$fields = CustomField::with('fieldset')->where("type", Helper::$itemtypes_having_custom_fields[$request->get('tab', 0)])->get();
return view('custom_fields.index')->with('custom_fieldsets', $fieldsets)->with('custom_fields', $fields);
}
@ -110,8 +112,10 @@ class CustomFieldsController extends Controller
"auto_add_to_fieldsets" => $request->get("auto_add_to_fieldsets", 0),
"show_in_listview" => $request->get("show_in_listview", 0),
"show_in_requestable_list" => $request->get("show_in_requestable_list", 0),
"user_id" => Auth::id()
"user_id" => Auth::id(),
]);
// not mass-assignable; must be manual
$field->type = Helper::$itemtypes_having_custom_fields[$request->get('tab')];
if ($request->filled('custom_format')) {
@ -131,7 +135,7 @@ class CustomFieldsController extends Controller
}
return redirect()->route('fields.index')->with('success', trans('admin/custom_fields/message.field.create.success'));
return redirect()->route('fields.index', ['tab' => $request->get('tab', 0)])->with('success', trans('admin/custom_fields/message.field.create.success'));
}
return redirect()->back()->with('selected_fieldsets', $request->input('associate_fieldsets'))->withInput()
@ -188,8 +192,8 @@ class CustomFieldsController extends Controller
return redirect()->back()->withErrors(['message' => 'Field is in-use']);
}
$field->delete();
return redirect()->route("fields.index")
->with("success", trans('admin/custom_fields/message.field.delete.success'));
return redirect()->route('fields.index', ['tab' => Request::query('tab', 0)])
->with('success', trans('admin/custom_fields/message.field.delete.success'));
}
return redirect()->back()->withErrors(['message' => 'Field does not exist']);
@ -290,7 +294,7 @@ class CustomFieldsController extends Controller
$field->fieldset()->sync([]);
}
return redirect()->route('fields.index')->with('success', trans('admin/custom_fields/message.field.update.success'));
return redirect()->route('fields.index', ['tab' => $request->get('tab', 0)])->with('success', trans('admin/custom_fields/message.field.update.success'));
}
return redirect()->back()->withInput()->with('error', trans('admin/custom_fields/message.field.update.error'));

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Helpers\Helper;
use App\Models\AssetModel;
use App\Models\CustomField;
use App\Models\CustomFieldset;
@ -96,6 +97,8 @@ class CustomFieldsetsController extends Controller
$fieldset = new CustomFieldset([
'name' => $request->get('name'),
'user_id' => Auth::user()->id,
'type' => Helper::$itemtypes_having_custom_fields[$request->get('tab')]
// 'sub' =>
]);
$validator = Validator::make($request->all(), $fieldset->rules);

View file

@ -2,6 +2,8 @@
namespace App\Http\Livewire;
use App\Models\Asset;
use App\Models\DefaultValuesForCustomFields;
use Livewire\Component;
use App\Models\CustomFieldset;
@ -30,7 +32,7 @@ class CustomFieldSetDefaultValuesForModel extends Component
$this->fields = CustomFieldset::find($this->fieldset_id)->fields;
}
$this->add_default_values = ($this->model->defaultValues->count() > 0);
$this->add_default_values = (DefaultValuesForCustomFields::forPivot($this->model, Asset::class)->count() > 0);
}
public function updatedFieldsetId()

View file

@ -109,7 +109,7 @@ class Importer extends Component
if ($type == "asset") {
// add Custom Fields after a horizontal line
$results['-'] = "———" . trans('admin/custom_fields/general.custom_fields') . "———’";
foreach (CustomField::orderBy('name')->get() as $field) {
foreach (CustomField::where('type', \App\Models\Asset::class)->orderBy('name')->get() as $field) { // TODO - generalize?
$results[$field->db_column_name()] = $field->name;
}
}

View file

@ -34,12 +34,14 @@ class CustomFieldRequest extends FormRequest
case 'POST':
{
$rules['name'] = 'required|unique:custom_fields';
$rules['tab'] = 'required';
break;
}
// Save all fields
case 'PUT':
$rules['name'] = 'required';
$rules['tab'] = 'required';
break;
// Save only what's passed

View file

@ -3,6 +3,8 @@
namespace App\Http\Transformers;
use App\Helpers\Helper;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\CustomFieldset;
use Illuminate\Database\Eloquent\Collection;
@ -21,8 +23,13 @@ class CustomFieldsetsTransformer
public function transformCustomFieldset(CustomFieldset $fieldset)
{
$fields = $fieldset->fields;
$models = $fieldset->models;
$models = [];
$modelsArray = [];
if ($fieldset->type == Asset::class) {
\Log::debug("Item pivot id is: ".$fieldset->item_pivot_id);
$models = AssetModel::where('fieldset_id', $fieldset->id)->get();
\Log::debug("And the models object count is: ".$models->count());
}
foreach ($models as $model) {
$modelsArray[] = [
@ -30,15 +37,21 @@ class CustomFieldsetsTransformer
'name' => e($model->name),
];
}
\Log::debug("Models array is: ".print_r($modelsArray,true));
$array = [
'id' => (int) $fieldset->id,
'name' => e($fieldset->name),
'fields' => (new CustomFieldsTransformer)->transformCustomFields($fields, $fieldset->fields_count),
'models' => (new DatatablesTransformer)->transformDatatables($modelsArray, $fieldset->models_count),
'customizables' => (new DatatablesTransformer)->transformDatatables($fieldset->customizables(),count($fieldset->customizables())),
'created_at' => Helper::getFormattedDateObject($fieldset->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($fieldset->updated_at, 'datetime'),
'type' => $fieldset->type,
];
if ($fieldset->type == Asset::class) {
// TODO - removeme - legacy column just for Assets?
$array['models'] = (new DatatablesTransformer)->transformDatatables($modelsArray, count($modelsArray));
}
return $array;
}

View file

@ -28,9 +28,10 @@ class AssetImporter extends ItemImporter
// ItemImporter handles the general fetching.
parent::handle($row);
// FIXME : YUP!!!!! This shit needs to go (?) Yeah?
if ($this->customFields) {
foreach ($this->customFields as $customField) {
$customFieldValue = $this->array_smart_custom_field_fetch($row, $customField);
$customFieldValue = $this->array_smart_custom_field_fetch($row, $customField); // TODO/FIXME - this might require a new 'mode' on customFill()?
if ($customFieldValue) {
if ($customField->field_encrypted == 1) {
@ -40,7 +41,7 @@ class AssetImporter extends ItemImporter
$this->item['custom_fields'][$customField->db_column_name()] = $customFieldValue;
$this->log('Custom Field '.$customField->name.': '.$customFieldValue);
}
} else {
} else { // FIXME - think this through? Do we want to blank this? Is that how other stuff works?
// Clear out previous data.
$this->item['custom_fields'][$customField->db_column_name()] = null;
}

View file

@ -175,7 +175,7 @@ abstract class Importer
* @author Daniel Meltzer
* @since 5.0
*/
protected function populateCustomFields($headerRow)
protected function populateCustomFields($headerRow) // FIXME - what in the actual fuck is this.
{
// Stolen From https://adamwathan.me/2016/07/14/customizing-keys-when-mapping-collections/
// This 'inverts' the fields such that we have a collection of fields indexed by name.

View file

@ -8,6 +8,8 @@ use App\Exceptions\CheckoutNotAllowed;
use App\Helpers\Helper;
use App\Http\Traits\UniqueUndeletedTrait;
use App\Models\Traits\Acceptable;
use App\Models\Traits\Customizable;
use App\Models\Traits\HasCustomFields;
use App\Models\Traits\Searchable;
use App\Presenters\Presentable;
use AssetPresenter;
@ -17,6 +19,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Watson\Validating\ValidatingTrait;
@ -37,8 +40,21 @@ class Asset extends Depreciable
public const ASSET = 'asset';
public const USER = 'user';
use Acceptable;
use Acceptable, HasCustomFields;
public function getFieldsetKey(): object|int|null
{
return $this->model;
}
public static function getFieldsetUsers(int $fieldset_id): array
{
$models = [];
foreach (AssetModel::where("fieldset_id", $fieldset_id)->get() as $model) {
$models[route('models.show', $model->id)] = $model->name . (($model->model_number) ? ' (' . $model->model_number . ')' : '');
}
return $models;
}
/**
* Run after the checkout acceptance was declined by the user
*
@ -200,41 +216,7 @@ class Asset extends Depreciable
}
$this->attributes['expected_checkin'] = $value;
}
/**
* This handles the custom field validation for assets
*
* @var array
*/
public function save(array $params = [])
{
if ($this->model_id != '') {
$model = AssetModel::find($this->model_id);
if (($model) && ($model->fieldset)) {
foreach ($model->fieldset->fields as $field){
if($field->format == 'BOOLEAN'){
$this->{$field->db_column} = filter_var($this->{$field->db_column}, FILTER_VALIDATE_BOOLEAN);
}
}
$this->rules += $model->fieldset->validation_rules();
if ($this->model->fieldset){
foreach ($this->model->fieldset->fields as $field){
if($field->format == 'BOOLEAN'){
$this->{$field->db_column} = filter_var($this->{$field->db_column}, FILTER_VALIDATE_BOOLEAN);
}
}
}
}
}
return parent::save($params);
}
public function getDisplayNameAttribute()
{
return $this->present()->name();

View file

@ -142,6 +142,7 @@ class AssetModel extends SnipeModel
*/
public function fieldset()
{
// this is actually OK - we don't *need* to do this, but it's okay to make references from Model to fieldset
return $this->belongsTo(\App\Models\CustomFieldset::class, 'fieldset_id');
}
@ -150,18 +151,6 @@ class AssetModel extends SnipeModel
return $this->fieldset()->first()->fields();
}
/**
* Establishes the model -> custom field default values relationship
*
* @author hannah tinkler
* @since [v4.3]
* @return \Illuminate\Database\Eloquent\Relations\Relation
*/
public function defaultValues()
{
return $this->belongsToMany(\App\Models\CustomField::class, 'models_custom_fields')->withPivot('default_value');
}
/**
* Gets the full url for the image
*

View file

@ -16,7 +16,7 @@ class CustomField extends Model
UniqueUndeletedTrait;
/**
* Custom field predfined formats
* Custom field predefined formats
*
* @var array
*/
@ -82,30 +82,19 @@ class CustomField extends Model
'show_in_requestable_list',
];
/**
* This is confusing, since it's actually the custom fields table that
* we're usually modifying, but since we alter the assets table, we have to
* say that here, otherwise the new fields get added onto the custom fields
* table instead of the assets table.
*
* @author [Brady Wetherington] [<uberbrady@gmail.com>]
* @since [v3.0]
*/
public static $table_name = 'assets';
/**
* Convert the custom field's name property to a db-safe string.
*
* We could probably have used str_slug() here but not sure what it would
* do with previously existing values. - @snipe
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v3.4]
* @return string
* @since [v3.4]
* @author [A. Gianotto] [<snipe@snipe.net>]
*/
public static function name_to_db_name($name)
{
return '_snipeit_'.preg_replace('/[^a-zA-Z0-9]/', '_', strtolower($name));
return '_snipeit_' . preg_replace('/[^a-zA-Z0-9]/', '_', strtolower($name));
}
/**
@ -116,23 +105,22 @@ class CustomField extends Model
* if they have changed, so we handle that here so that we don't have to remember
* to do it in the controllers.
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v3.4]
* @return bool
* @since [v3.4]
* @author [A. Gianotto] [<snipe@snipe.net>]
*/
public static function boot()
{
parent::boot();
self::created(function ($custom_field) {
// Column already exists on the assets table - nothing to do here.
// This *shouldn't* happen in the wild.
if (Schema::hasColumn(self::$table_name, $custom_field->db_column)) {
if (Schema::hasColumn($custom_field->getTableName(), $custom_field->db_column)) {
return false;
}
// Update the column name in the assets table
Schema::table(self::$table_name, function ($table) use ($custom_field) {
Schema::table($custom_field->getTableName(), function ($table) use ($custom_field) {
$table->text($custom_field->convertUnicodeDbSlug())->nullable();
});
@ -145,7 +133,7 @@ class CustomField extends Model
// Column already exists on the assets table - nothing to do here.
if ($custom_field->isDirty('name')) {
if (Schema::hasColumn(self::$table_name, $custom_field->convertUnicodeDbSlug())) {
if (Schema::hasColumn($custom_field->getTableName(), $custom_field->convertUnicodeDbSlug())) {
return true;
}
@ -155,7 +143,7 @@ class CustomField extends Model
$platform->registerDoctrineTypeMapping('enum', 'string');
// Rename the field if the name has changed
Schema::table(self::$table_name, function ($table) use ($custom_field) {
Schema::table($custom_field->getTableName(), function ($table) use ($custom_field) {
$table->renameColumn($custom_field->convertUnicodeDbSlug($custom_field->getOriginal('name')), $custom_field->convertUnicodeDbSlug());
});
@ -171,12 +159,19 @@ class CustomField extends Model
// Drop the assets column if we've deleted it from custom fields
self::deleting(function ($custom_field) {
return Schema::table(self::$table_name, function ($table) use ($custom_field) {
return Schema::table($custom_field->getTableName(), function ($table) use ($custom_field) {
$table->dropColumn($custom_field->db_column);
});
});
}
public function getTableName()
{
$type = $this->type;
$instance = new $type();
return $instance->getTable();
}
/**
* Establishes the customfield -> fieldset relationship
*
@ -207,31 +202,23 @@ class CustomField extends Model
}
/**
* Establishes the customfield -> default values relationship
*
* @author Hannah Tinkler
* @since [v3.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
*/
public function defaultValues()
{
return $this->belongsToMany(\App\Models\AssetModel::class, 'models_custom_fields')->withPivot('default_value');
}
/**
* Returns the default value for a given model using the defaultValues
* Returns the default value for a given 'item' using the defaultValues
* relationship
*
* @param int $modelId
* @return string
*/
public function defaultValue($modelId)
public function defaultValue($pivot_id)
{
return $this->defaultValues->filter(function ($item) use ($modelId) {
return $item->pivot->asset_model_id == $modelId;
})->map(function ($item) {
return $item->pivot->default_value;
})->first();
/*
below, you might think you need to add:
where('type', $this->type),
but the type can be inferred from by the custom_field itself (which also has a type)
can't use forPivot() here because we don't have an object yet. (TODO?)
*/
DefaultValuesForCustomFields::where('item_pivot_id', $pivot_id)->where('custom_field_id', $this->id)->first()?->default_value; //TODO - php8-only operator!
}
/**

View file

@ -22,6 +22,7 @@ class CustomFieldset extends Model
*/
public $rules = [
'name' => 'required|unique:custom_fieldsets',
''
];
/**
@ -50,11 +51,13 @@ class CustomFieldset extends Model
*
* @author [Brady Wetherington] [<uberbrady@gmail.com>]
* @since [v3.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
* @return \Illuminate\Database\Eloquent\Collection
*/
public function models()
public function customizables() // TODO - I don't like this name, but I can't think of anything better
{
return $this->hasMany(\App\Models\AssetModel::class, 'fieldset_id');
$customizable_class_name = $this->type; //TODO - copypasta from Customizable trait?
\Log::debug("Customizable Class name is: ".$customizable_class_name);
return $customizable_class_name::getFieldsetUsers($this->id);
}
/**
@ -79,6 +82,7 @@ class CustomFieldset extends Model
*/
public function validation_rules()
{
\Log::debug("CALLING validation_rules FOR customfiledsets!");
$rules = [];
foreach ($this->fields as $field) {
$rule = [];
@ -92,7 +96,12 @@ class CustomFieldset extends Model
$rule[] = 'unique_undeleted';
}
array_push($rule, $field->attributes['format']);
\Log::debug("Field Format for".$field->name." is: ".$field->format);
if($field->format == 'DATE') { //we do a weird mutator thing, it's confusing - but, yes, it's all-caps
$rule[] = 'date_format:Y-m-d';
} else {
array_push($rule, $field->attributes['format']);
}
$rules[$field->db_column_name()] = $rule;
// add not_array to rules for all fields but checkboxes

View file

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Watson\Validating\ValidatingTrait;
class DefaultValuesForCustomFields extends Model
{
use HasFactory, ValidatingTrait;
protected $rules = [
'type' => 'required'
];
public $timestamps = false;
public function field() {
return $this->belongsTo('custom_fields');
}
// There is, effectively, another 'relation' here, but it's weirdly polymorphic
// and impossible to represent in Laravel.
// we have a 'type', and we have an 'item_pivot_id' -
// For example, in Assets the 'type' would be App\Models\Asset, and the 'item_pivot_id' would be a model_id
// I can't come up with any way to represent this in Laravel/Eloquent
// TODO: might be getting overly-fancy here; maybe just want to do an ID? Instead of an Eloquent Model?
public function scopeForPivot(Builder $query, Model $item, string $class) {
return $query->where('item_pivot_id', $item->id)->where('type', $class);
}
}

View file

@ -0,0 +1,138 @@
<?php
namespace App\Models\Traits;
use App\Models\CustomField;
use App\Models\CustomFieldset;
use Illuminate\Support\Collection;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use App\Models\DefaultValuesForCustomFields;
/*********************************
* Trait HasCustomFields
* @package App\Models\Traits
*
* How to use: declare a PHP function getFieldset that will return your fieldset (not the ID, the actual set)
*
*/
trait HasCustomFields
{
protected static function bootHasCustomFields()
{
// https://tech.chrishardie.com/2022/define-fire-listen-custom-laravel-model-events-trait/
static::registerModelEvent('validating', function ($model, $event) {
\Log::debug("Uh, something happened? Something good, maybe?");
\Log::debug("model: $model, event: $event");
self::augmentValidationRulesForCustomFields($model);
});
}
/***************
* @return CustomFieldset|null
*
* This function by default will use the "getFieldsetKey()" method to
* return the customFieldset (or null) for this particular item. If
* necessary, you can override this method if your getFieldsetKey()
* cannot respond to `->fieldset` or `->id`.
*/
public function getFieldset(): ?CustomFieldset {
$pivot = $this->getFieldsetKey();
if(is_int($pivot)) { //why does this look just like the other thing? (below, look for is_int()
return Fieldset::find($pivot);
}
return $pivot->fieldset;
}
/**********************
* @return Object|int|null
* (if this is in PHP 8.0, can we just put that as the signature?)
*
* This is the main method you have to override. It should either return an
* Object who you can call `->fieldset` on and get a fieldset object, and also
* be able to call `->id` on to get a unique key to be able to show custom fields.
* For example, for Assets, the element that is returned is the 'model' for the Asset.
* For something like Users, which will probably have only one universal set of custom fields,
* it should just return the Fieldset ID for it. Or, if there are no custom fields, it should
* return null
*/
abstract public function getFieldsetKey(): Object|int|null; // php v8 minimum, GOOD. TODO
/***********************
* @param int $fieldset_id
* @return Collection
*
* This is the main method you need to override to return a list of things that are *using* this fieldset
* The format is an array with keys: a URL, and values. So, for assets, it might return
* {
* "models/14" => "MacBook Pro 13 (model no: 12345)"
* }
*/
abstract public static function getFieldsetUsers(int $fieldset_id): array;
public static function augmentValidationRulesForCustomFields($model) {
\Log::debug("Augmenting validation rules for custom fields!!!!!!");
$fieldset = $model->getFieldset();
if ($fieldset) {
foreach ($fieldset->fields as $field){
if($field->format == 'BOOLEAN'){ // TODO - this 'feels' like entanglement of concerns?
$model->{$field->db_column} = filter_var($model->{$model->db_column}, FILTER_VALIDATE_BOOLEAN);
}
}
if(!$model->rules) {
$model->rules = [];
}
$model->rules += $model->getFieldset()->validation_rules();
\Log::debug("FINAL RULES ARE: ".print_r($model->rules,true));
}
}
public function getDefaultValue(CustomField $field)
{
$pivot = $this->getFieldsetKey(); // TODO - feels copypasta-ish?
$key_id = null;
if( is_int($pivot) ) { // TODO: *WHY* does this code repeat?!
$key_id = $pivot; // now we're done
} elseif( is_object($pivot) ) {
$key_id = $pivot?->id;
}
if(is_null($key_id)) {
return;
}
// TODO - begninng to think my custom scope really should be just an integer :/
return DefaultValuesForCustomFields::where('type',self::class)
->where('custom_field_id',$field->id)
->where('item_pivot_id',$key_id)->first()?->default_value;
}
public function customFill(Request $request, User $user, bool $shouldSetDefaults = false) {
$this->_filled = true;
if ($this->getFieldset()) {
foreach ($this->getFieldset()->fields as $field) {
if (is_array($request->input($field->db_column))) {
$field_value = implode(', ', $request->input($field->db_column));
} else {
$field_value = $request->input($field->db_column);
}
if ($shouldSetDefaults && (is_null($field_value) || $field_value === '')) {
$field_value = $this->getDefaultValue($field);
}
if ($field->field_encrypted == '1') {
if ($user->can('admin')) {
$this->{$field->db_column} = \Crypt::encrypt($field_value);
}
} else {
$this->{$field->db_column} = $request->input($field->db_column);
}
}
}
}
}

View file

@ -2,8 +2,10 @@
namespace App\Presenters;
use App\Models\Asset;
use App\Models\CustomField;
use Carbon\CarbonImmutable;
use App\Models\CustomFieldset;
use DateTime;
/**
@ -299,10 +301,16 @@ class AssetPresenter extends Presenter
// models. We only pass the fieldsets that pertain to each asset (via their model) so that we
// don't junk up the REST API with tons of custom fields that don't apply
$fields = CustomField::whereHas('fieldset', function ($query) {
$query->whereHas('models');
})->get();
//only get fieldsets that have fields
$fieldsets = CustomFieldset::where("type", Asset::class)->whereHas('fields')->get();
$ids = [];
foreach($fieldsets as $fieldset) {
if (count($fieldset->customizables()) > 0) { //only get fieldsets that are 'in use'
$ids[] = $fieldset->id;
}
}
$fields = CustomField::whereIn('id',$ids)->get();
// Note: We do not need to e() escape the field names here, as they are already escaped when
// they are presented in the blade view. If we escape them here, custom fields with quotes in their
// name can break the listings page. - snipe

View file

@ -87,7 +87,6 @@ class AuthServiceProvider extends ServiceProvider
]);
$this->registerPolicies();
//Passport::routes(); //this is no longer required in newer passport versions
Passport::tokensExpireIn(Carbon::now()->addYears(config('passport.expiration_years')));
Passport::refreshTokensExpireIn(Carbon::now()->addYears(config('passport.expiration_years')));
Passport::personalAccessTokensExpireIn(Carbon::now()->addYears(config('passport.expiration_years')));

View file

@ -1,5 +1,7 @@
<?php
use Illuminate\Http\Request;
/*
|--------------------------------------------------------------------------
| DO NOT EDIT THIS FILE DIRECTLY.
@ -57,6 +59,9 @@ return [
*
* @link https://symfony.com/doc/current/deployment/proxies.html
*/
// 'headers' => Illuminate\Http\Request::HEADER_X_FORWARDED_ALL, //this is mostly handled already
'headers' => Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB,
];

View file

@ -387,4 +387,15 @@ class AssetFactory extends Factory
$asset->setValidating(true);
});
}
public function withComplicatedCustomFields()
{
return $this->state(function () {
return [
'model_id' => function () {
return AssetModel::where('name', 'complicated')->first() ?? AssetModel::factory()->complicated();
}
];
});
}
}

View file

@ -448,4 +448,13 @@ class AssetModelFactory extends Factory
];
});
}
public function complicated()
{
return $this->state(function () {
return [
'name' => 'Complicated fieldset'
];
})->for(CustomFieldSet::factory()->complicated(), 'fieldset');
}
}

View file

@ -27,6 +27,7 @@ class CustomFieldFactory extends Factory
'element' => 'text',
'auto_add_to_fieldsets' => '0',
'show_in_requestable_list' => '0',
'type' => 'App\\Models\\Asset'
];
}
@ -76,7 +77,7 @@ class CustomFieldFactory extends Factory
{
return $this->state(function () {
return [
'name' => 'MAC Address',
'name' => 'MAC Address EXPLICIT',
'format' => 'regex:/^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/',
];
});
@ -93,6 +94,15 @@ class CustomFieldFactory extends Factory
});
}
public function plainText()
{
return $this->state(function () {
return [
'name' => 'plain_text',
];
});
}
public function testCheckbox()
{
return $this->state(function () {
@ -117,4 +127,13 @@ class CustomFieldFactory extends Factory
});
}
public function date()
{
return $this->state(function () {
return [
'name' => 'date',
'format' => 'date'
];
});
}
}

View file

@ -2,8 +2,8 @@
namespace Database\Factories;
use App\Models\CustomFieldset;
use App\Models\CustomField;
use App\Models\CustomFieldset;
use Illuminate\Database\Eloquent\Factories\Factory;
class CustomFieldsetFactory extends Factory
@ -58,13 +58,13 @@ class CustomFieldsetFactory extends Factory
{
return $this->afterCreating(function (CustomFieldset $fieldset) use ($fields) {
if (empty($fields)) {
$mac_address = CustomField::factory()->macAddress()->create();
$ram = CustomField::factory()->ram()->create();
$cpu = CustomField::factory()->cpu()->create();
$mac_address = CustomField::factory()->macAddress()->create();
$ram = CustomField::factory()->ram()->create();
$cpu = CustomField::factory()->cpu()->create();
$fieldset->fields()->attach($mac_address, ['order' => '1', 'required' => false]);
$fieldset->fields()->attach($ram, ['order' => '2', 'required' => false]);
$fieldset->fields()->attach($cpu, ['order' => '3', 'required' => false]);
$fieldset->fields()->attach($mac_address, ['order' => '1', 'required' => false]);
$fieldset->fields()->attach($ram, ['order' => '2', 'required' => false]);
$fieldset->fields()->attach($cpu, ['order' => '3', 'required' => false]);
} else {
foreach ($fields as $field) {
$fieldset->fields()->attach($field, ['order' => '1', 'required' => false]);
@ -72,4 +72,16 @@ class CustomFieldsetFactory extends Factory
}
});
}
public function complicated()
{
//$mac = CustomField::factory()->macAddress()->create();
return $this->state(function () {
return [
'name' => 'complicated'
];
})->hasAttached(CustomField::factory()->macAddress(), ['required' => false, 'order' => 0], 'fields')
->hasAttached(CustomField::factory()->plainText(), ['required' => true, 'order' => 1], 'fields')
->hasAttached(CustomField::factory()->date(), ['required' => false, 'order' => 2], 'fields');
}
}

View file

@ -23,8 +23,8 @@ function updateLegacyColumnName($customfield)
$name_to_db_name = CustomField::name_to_db_name($customfield->name);
//\Log::debug('Trying to rename '.$name_to_db_name." to ".$customfield->convertUnicodeDbSlug()."...\n");
if (Schema::hasColumn(CustomField::$table_name, $name_to_db_name)) {
return Schema::table(CustomField::$table_name,
if (Schema::hasColumn('assets', $name_to_db_name)) {
return Schema::table('assets',
function ($table) use ($name_to_db_name, $customfield) {
$table->renameColumn($name_to_db_name, $customfield->convertUnicodeDbSlug());
}
@ -81,8 +81,8 @@ class FixUtf8CustomFieldColumnNames extends Migration
// "_snipeit_imei_1" becomes "_snipeit_imei"
$legacyColumnName = (string) Str::of($currentColumnName)->replaceMatches('/_(\d)+$/', '');
if (Schema::hasColumn(CustomField::$table_name, $currentColumnName)) {
Schema::table(CustomField::$table_name, function (Blueprint $table) use ($currentColumnName, $legacyColumnName) {
if (Schema::hasColumn('assets', $currentColumnName)) {
Schema::table('assets', function (Blueprint $table) use ($currentColumnName, $legacyColumnName) {
$table->renameColumn(
$currentColumnName,
$legacyColumnName

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use App\Models\CustomField;
use App\Models\Asset;
class AddTypeToCustomFields extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('custom_fields', function (Blueprint $table) {
//
$table->text('type')->default('App\\Models\\Asset'); // TODO this default is needed for a not-nullable column, I guess? I don't like this because it will silently 'fix' errors we should properly 'fix'
});
CustomField::query()->update(['type' => Asset::class]); // TODO - is this still necessary with that 'default'?
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('custom_fields', function (Blueprint $table) {
//
$table->dropColumn('type');
});
}
}

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use App\Models\CustomFieldset;
use App\Models\Asset;
class AddTypeToCustomFieldsets extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('custom_fieldsets', function (Blueprint $table) {
//
$table->text('type')->default('App\\Models\\Asset');
});
CustomFieldset::query()->update(['type' => Asset::class]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('custom_fieldsets', function (Blueprint $table) {
//
$table->dropColumn('type');
});
}
}

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class GeneralizeDefaultValuesForCustomFields extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::rename('models_custom_fields', 'default_values_for_custom_fields');
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::rename('default_values_for_custom_fields', 'models_custom_fields');
}
}

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddTypeColumnToDefaultValuesForCustomFields extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('default_values_for_custom_fields', function (Blueprint $table) {
$table->text('type')->default('App\\Models\\Asset');
$table->renameColumn('asset_model_id','item_pivot_id');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('default_values_for_custom_fields', function (Blueprint $table) {
$table->dropColumn('type');
$table->renameColumn('item_pivot_id','asset_model_id');
});
}
}

View file

@ -30,6 +30,8 @@
@endif
@csrf
<input type="hidden" name="tab" value="{{ Request::query('tab') }}" />
<div class="row">
<div class="col-md-12">
<div class="box box-default">

View file

@ -12,6 +12,7 @@
@section('inputFields')
@include ('partials.forms.edit.name', ['translated_name' => trans('general.name')])
<input type="hidden" name="tab" value="{{ Request::query('tab') }}" />
@stop

View file

@ -13,6 +13,26 @@
@section('content')
@can('view', \App\Models\CustomFieldset::class)
<div class="row">
<div calss="col-md-12">
<div class="box box-default">
<div class="box-body">
<div class="nav-tabs-custom">
<ul class="nav nav-tabs">
{{-- TODO - generalize this so it's less 'hardcoded' --}}
<li {!! !Request::query('tab') ? 'class="active"': '' !!}><a
href="{{ route("fields.index",["tab" => 0]) }}">Asset Custom Fields</a></li>
<li {!! Request::query('tab') == 1 ? 'class="active"': '' !!}><a
href="{{ route("fields.index",["tab" => 1]) }}">Users</a></li>
<li {!! Request::query('tab') == 2 ? 'class="active"': '' !!}><a
href="{{ route("fields.index",["tab" => 2]) }}">Accessories</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="box box-default">
@ -21,7 +41,9 @@
<h2 class="box-title">{{ trans('admin/custom_fields/general.fieldsets') }}</h2>
<div class="box-tools pull-right">
@can('create', \App\Models\CustomFieldset::class)
<a href="{{ route('fieldsets.create') }}" class="btn btn-sm btn-primary" data-tooltip="true" title="{{ trans('admin/custom_fields/general.create_fieldset_title') }}">{{ trans('admin/custom_fields/general.create_fieldset') }}</a>
<a href="{{ route('fieldsets.create',['tab' => Request::query('tab',0)]) }}" class="btn btn-sm btn-primary"
data-tooltip="true"
title="{{ trans('admin/custom_fields/general.create_fieldset_title') }}">{{ trans('admin/custom_fields/general.create_fieldset') }}</a>
@endcan
</div>
</div><!-- /.box-header -->
@ -47,7 +69,7 @@
<tr>
<th>{{ trans('general.name') }}</th>
<th>{{ trans('admin/custom_fields/general.qty_fields') }}</th>
<th>{{ trans('admin/custom_fields/general.used_by_models') }}</th>
<th>{{ trans('admin/custom_fields/general.used_by_models') }}{{-- FIXME --}}</th>
<th>{{ trans('table.actions') }}</th>
</tr>
</thead>
@ -63,9 +85,9 @@
{{ $fieldset->fields->count() }}
</td>
<td>
@foreach($fieldset->models as $model)
<a href="{{ route('models.show', $model->id) }}" class="label label-default">{{ $model->name }}{{ ($model->model_number) ? ' ('.$model->model_number.')' : '' }}</a>
@foreach($fieldset->customizables() as $url => $name)
<a href="{{ $url }}" class="label label-default">{{ $name }}</a>
{{-- get_class($customizable) }}: {{ $customizable->name<br /> --}}
@endforeach
</td>
<td>
@ -88,10 +110,13 @@
@can('delete', $fieldset)
{{ Form::open(['route' => array('fieldsets.destroy', $fieldset->id), 'method' => 'delete','style' => 'display:inline-block']) }}
@if($fieldset->models->count() > 0)
<button type="submit" class="btn btn-danger btn-sm disabled" data-tooltip="true" title="{{ trans('general.cannot_be_deleted') }}" disabled><i class="fas fa-trash"></i></button>
@if(count($fieldset->customizables()) > 0 /* TODO - hate 'customizables' */)
<button type="submit" class="btn btn-danger btn-sm disabled" data-tooltip="true"
title="{{ trans('general.cannot_be_deleted') }}" disabled><i class="fas fa-trash"></i>
</button>
@else
<button type="submit" class="btn btn-danger btn-sm" data-tooltip="true" title="{{ trans('general.delete') }}"><i class="fas fa-trash"></i></button>
<button type="submit" class="btn btn-danger btn-sm" data-tooltip="true"
title="{{ trans('general.delete') }}"><i class="fas fa-trash"></i></button>
@endif
{{ Form::close() }}
@endcan
@ -118,7 +143,9 @@
<h2 class="box-title">{{ trans('admin/custom_fields/general.custom_fields') }}</h2>
<div class="box-tools pull-right">
@can('create', \App\Models\CustomField::class)
<a href="{{ route('fields.create') }}" class="btn btn-sm btn-primary" data-tooltip="true" title="{{ trans('admin/custom_fields/general.create_field_title') }}">{{ trans('admin/custom_fields/general.create_field') }}</a>
<a href="{{ route('fields.create', ['tab' => Request::query('tab',0)]) }}" class="btn btn-sm btn-primary"
data-tooltip="true"
title="{{ trans('admin/custom_fields/general.create_field_title') }}">{{ trans('admin/custom_fields/general.create_field') }}</a>
@endcan
</div>

View file

@ -0,0 +1,79 @@
<?php
namespace Tests\Unit;
use App\Models\Asset;
use App\Models\CustomField;
use App\Models\CustomFieldset;
use App\Models\Traits\HasCustomFields;
use Illuminate\Support\Collection;
use Tests\Support\InteractsWithSettings;
use Tests\TestCase;
use Illuminate\Support\Facades\Schema;
class HasCustomFieldsTraitTest extends TestCase
{
use InteractsWithSettings; //seems bonkers, but Assets needs it? (for currency calculation?)
public function testAssetSchema()
{
$asset = Asset::factory()->withComplicatedCustomFields()->create();
$this->assertEquals($asset->model->fieldset->fields->count(), 3,'Custom Fieldset should have exactly 3 custom fields');
$this->assertTrue(Schema::hasColumn('assets','_snipeit_mac_address_explicit_2'),'Assets table should have MAC address column');
$this->assertTrue(Schema::hasColumn('assets','_snipeit_plain_text_3'),'Assets table should have MAC address column');
$this->assertTrue(Schema::hasColumn('assets','_snipeit_date_4'),'Assets table should have MAC address column');
}
public function testRequired()
{
$asset = Asset::factory()->withComplicatedCustomFields()->create();
$this->assertFalse($asset->save(),'save() should fail due to required text field');
}
public function testFormat()
{
$asset = Asset::factory()->withComplicatedCustomFields()->make();
$asset->_snipeit_plain_text_3 = 'something';
$asset->_snipeit_mac_address_explicit_2 = 'fartsssssss';
$this->assertFalse($asset->save(), 'should fail due to bad MAC address');
}
public function testDate()
{
// \Log::error("uh, what the heck is going on here?!");
$asset = Asset::factory()->withComplicatedCustomFields()->make();
$asset->_snipeit_plain_text_3 = 'some text';
$asset->_snipeit_date_4 = '1/2/2023';
// $asset->save();
// dd($asset);
$this->assertFalse($asset->save(),'Should fail due to incorrectly formatted date.');
}
public function testSaveMinimal()
{
$asset = Asset::factory()->withComplicatedCustomFields()->make();
$asset->_snipeit_plain_text_3 = "some text";
$this->assertTrue($asset->save(),"Asset should've saved okay, the one required field was filled out");
}
public function testSaveMaximal()
{
$asset = Asset::factory()->withComplicatedCustomFields()->make();
$asset->_snipeit_plain_text_3 = "some text";
$asset->_snipeit_date_4 = "2023-01-02";
$asset->_snipeit_mac_address_explicit_2 = "ff:ff:ff:ff:ff:ff";
$this->assertTrue($asset->save(),"Asset should've saved okay, the one required field was filled out, and so were the others");
}
public function testJsonPost()
{
$asset = Asset::factory()->withComplicatedCustomFields()->make();
$response = $this->postJson('/api/v1/hardware', [
]);
$response->assertStatus(200);
}
}