snipe-it icon indicating copy to clipboard operation
snipe-it copied to clipboard

[Feature Request]: Remove Default Locations of Multiple Assets at Once

Open JoneSodaaa opened this issue 3 years ago • 3 comments

Is your feature request related to a problem? Please describe.

Long ago, I imported assets in with default locations via CSV. After checking them in, they still show up in the location because they are assigned to the default location. I can only remove the default locations one-by-one. If I select all assets > edit,, I am not given an option to remove the default location like I would when I am editing a single asset.

Describe the solution you'd like

The ability to remove the default location for several assets at once.

Describe alternatives you've considered

No response

Additional context

Editing Multiple Assets: 2022-09-03_20-01-15

Editing a Single Asset: 2022-09-03_20-02-31

JoneSodaaa avatar Sep 04 '22 01:09 JoneSodaaa

I think this was resolved quite some time ago

snipe avatar Feb 10 '25 22:02 snipe

I don't think this is resolved, I'm running into the same issue. I want to mass remove both actual and default locations from assets, but in the bulk editor you can only update locations, not remove them.

Image

sammy-it avatar May 23 '25 16:05 sammy-it

I run into this problem quite frequently. Especially when archiving assets the locations remains and can't be deleted.

As a workaround I'm using this PowerShell script to remove the location and default location from all archived assets.

It's AI generated and far from perfect, but get's the work done.

Adjust it to your needs.

<#
.SYNOPSIS
  Clears location_id and rtd_location_id only for archived assets that have one of these fields set.
  Robust paging using limit & offset with duplicate-detection to avoid infinite loops.

.NOTES
  - Uses limit & offset (not page/per_page).
  - If the API returns 'total' / 'Total' we use that to stop early.
  - If no total is present we stop when a page returns no *new* asset IDs (duplicate detection).
  - Also has a MaxLoops fail-safe.

.PARAMETER BaseUrl
  Base URL for the Snipe-IT API (e.g. https://inventory.example.com/api/v1)

.PARAMETER Token
  API token (defaults to $env:INVENTAR_API_TOKEN)

.PARAMETER Limit
  Number of items to request per call (default 50). Snipe-IT may cap this (default 500 server-side as of some versions).

.PARAMETER Execute
  If supplied, PATCH requests will actually be executed. Without -Execute the script runs in DryRun mode.

.EXAMPLE
  # Dry run
  pwsh .\Clear-SnipeArchivedLocations.ps1 -BaseUrl "https://inventory.example.com/api/v1"

  # Execute changes
  pwsh .\Clear-SnipeArchivedLocations.ps1 -BaseUrl "https://inventory.example.com/api/v1" -Execute
#>

param(
    [string]$BaseUrl = "https://inventory.example.com/api/v1",
    [string]$Token = $env:INVENTAR_API_TOKEN,
    [int]$Limit = 50,
    [switch]$Execute
)

if (-not $Token) {
    Write-Error "No API token found. Set INVENTAR_API_TOKEN or pass -Token."
    exit 1
}

$Headers = @{
    "Authorization" = "Bearer $Token"
    "Accept"        = "application/json"
    "Content-Type"  = "application/json"
}

function Get-ArchivedHardware_Paged {
    param(
        [string]$ApiBase,
        [int]$Limit
    )

    $offset = 0
    $all = @()
    $seenIds = @{} # hashtable for seen IDs
    $MaxLoops = 100 # failsafe upper bound
    $loop = 0

    while ($true) {
        $loop++
        if ($loop -gt $MaxLoops) {
            Write-Warning "Reached max loop count ($MaxLoops). Stopping to avoid infinite loop."
            break
        }

        # build URI with limit & offset; include status=Archived
        $uri = "{0}/hardware?status=Archived&limit={1}&offset={2}&sort=created_at&order=desc" -f $ApiBase.TrimEnd('/'), $Limit, $offset
        try {
            # Use Invoke-RestMethod so JSON -> PSObject
            $resp = Invoke-RestMethod -Method Get -Uri $uri -Headers $Headers -ErrorAction Stop
        }
        catch {
            Write-Error ("Error fetching offset {0}: {1}" -f $offset, $_.Exception.Message)
            break
        }

        # Try to extract list rows in a few different known shapes
        if ($null -ne $resp.rows) {
            $rows = $resp.rows
        }
        elseif ($null -ne $resp.Rows) {
            $rows = $resp.Rows
        }
        elseif ($null -ne $resp.payload) {
            $rows = $resp.payload
        }
        elseif ($null -ne $resp.data) {
            $rows = $resp.data
        }
        elseif ($resp -is [System.Array]) {
            $rows = $resp
        }
        else {
            # fallback: maybe resp contains a single object - wrap it
            $rows = @()
        }

        # Try to get total if provided by the API
        $total = $null
        if ($resp.PSObject.Properties.Match('total').Count -gt 0) { $total = $resp.total }
        elseif ($resp.PSObject.Properties.Match('Total').Count -gt 0) { $total = $resp.Total }
        elseif ($resp.PSObject.Properties.Match('count').Count -gt 0) { $total = $resp.count }
        elseif ($resp.PSObject.Properties.Match('Count').Count -gt 0) { $total = $resp.Count }

        # Logging
        Write-Host ("Fetched offset {0} (limit {1}) => returned {2} items. Total reported: {3}" -f $offset, $Limit, ($rows.Count), ($total -ne $null ? $total : "n/a"))

        # If the API tells us the total and we've passed it, stop
        if ($total -ne $null -and $offset -ge [int]$total) {
            Write-Host "Offset >= reported total. Stopping."
            break
        }

        # If no rows at all -> break
        if (-not $rows -or $rows.Count -eq 0) {
            Write-Host "No rows returned for this offset. Stopping."
            break
        }

        # Add only new (not seen) items to $all and detect duplicates
        $newFound = 0
        foreach ($r in $rows) {
            # robust ID extraction
            $id = $null
            if ($r.PSObject.Properties.Match('id').Count -gt 0) { $id = $r.id }
            elseif ($r.PSObject.Properties.Match('ID').Count -gt 0) { $id = $r.ID }

            if ($null -eq $id) { continue }

            if (-not $seenIds.ContainsKey($id)) {
                $seenIds[$id] = $true
                $all += $r
                $newFound++
            }
            else {
                # already seen this id
            }
        }

        if ($newFound -eq 0) {
            # No new IDs in this page -> likely we've looped or API returned duplicates -> stop.
            Write-Host "No new unique items in this page (all IDs already seen). Stopping to avoid infinite loop."
            break
        }

        # If response provided a total and we've now collected >= total, stop
        if ($total -ne $null -and $seenIds.Count -ge [int]$total) {
            Write-Host "Collected items >= reported total. Stopping."
            break
        }

        # prepare for next page
        $offset += $Limit

        # small delay optional (rate-limit safety)
        Start-Sleep -Milliseconds 200
    }

    return $all
}

function LocationNotEmpty {
    param($asset)

    $locId = $null
    $rtdId = $null

    if ($asset.PSObject.Properties.Match('location_id').Count -gt 0) { $locId = $asset.location_id }
    elseif ($asset.PSObject.Properties.Match('location').Count -gt 0) {
        if ($asset.location -is [System.Management.Automation.PSObject] -and $asset.location.id) {
            $locId = $asset.location.id
        }
        else {
            $locId = $asset.location
        }
    }

    if ($asset.PSObject.Properties.Match('rtd_location_id').Count -gt 0) { $rtdId = $asset.rtd_location_id }
    elseif ($asset.PSObject.Properties.Match('rtd_location').Count -gt 0) {
        if ($asset.rtd_location -is [System.Management.Automation.PSObject] -and $asset.rtd_location.id) {
            $rtdId = $asset.rtd_location.id
        }
        else {
            $rtdId = $asset.rtd_location
        }
    }

    $isLocSet = ($locId -ne $null) -and ($locId -ne 0) -and ($locId -ne "")
    $isRtdSet = ($rtdId -ne $null) -and ($rtdId -ne 0) -and ($rtdId -ne "")

    return ($isLocSet -or $isRtdSet)
}

function PatchClearLocations {
    param(
        [Parameter(Mandatory = $true)][int]$Id,
        [Parameter(Mandatory = $true)][string]$ApiBase,
        [switch]$DoIt
    )

    $uri = "{0}/hardware/{1}" -f $ApiBase.TrimEnd('/'), $Id

    $bodyObj = [PSCustomObject]@{
        location_id     = $null
        rtd_location_id = $null
    }
    $json = $bodyObj | ConvertTo-Json -Depth 5

    if ($DoIt) {
        try {
            $result = Invoke-RestMethod -Method Patch -Uri $uri -Headers $Headers -Body $json -ErrorAction Stop
            Write-Host ("Patched Asset {0}: OK" -f $Id)
            return $true
        }
        catch {
            Write-Warning ("Error patching Asset {0}: {1}" -f $Id, $_.Exception.Message)
            return $false
        }
    }
    else {
        Write-Host ("[DryRun] Would PATCH {0} with body: {1}" -f $uri, $json)
        return $null
    }
}

# --- Flow ---
Write-Host "Fetching archived assets (limit/offset paging) from $BaseUrl ..."
$archivedAssets = Get-ArchivedHardware_Paged -ApiBase $BaseUrl -Limit $Limit

if (-not $archivedAssets -or $archivedAssets.Count -eq 0) {
    Write-Warning "No archived assets found."
    exit 0
}

Write-Host ("Total archived assets loaded (unique): {0}" -f $archivedAssets.Count)

$targets = $archivedAssets | Where-Object { LocationNotEmpty $_ }
Write-Host ("Archived assets with set location/rtd_location: {0}" -f $targets.Count)

if ($targets.Count -eq 0) {
    Write-Host "No assets found that need cleaning. Exiting."
    exit 0
}

$summary = [PSCustomObject]@{
    TotalToPatch = $targets.Count
    Success      = 0
    Failed       = 0
    DryRun       = (-not $Execute)
}

foreach ($a in $targets) {
    $id = $a.id
    if (-not $id) { $id = $a.ID }
    if (-not $id) {
        Write-Warning "Asset without ID skipped: $($a | ConvertTo-Json -Depth 2)"
        $summary.Failed++
        continue
    }

    $tag = $a.asset_tag
    $name = $a.name

    Write-Host ("Processing Asset ID {0} (Tag: {1}, Name: {2})" -f $id, $tag, $name)

    $res = PatchClearLocations -Id $id -ApiBase $BaseUrl -DoIt:$Execute
    if ($res -eq $true) { $summary.Success++ } elseif ($res -eq $false) { $summary.Failed++ }
}

Write-Host "Done. Summary:"
$summary | Format-List

if (-not $Execute) {
    Write-Host ""
    Write-Host "Note: Ran in DryRun mode. To actually apply the changes, run the script with -Execute."
}

josobrate avatar Dec 04 '25 11:12 josobrate