Compare commits

3 Commits
1.0.0 ... main

Author SHA1 Message Date
9159d5013e v1.1 2025-08-18 16:55:45 +07:00
f19f3b0db1 add to inventroy 2025-08-18 11:39:06 +07:00
ca4532fa39 inventory management add/del 2025-08-18 11:28:55 +07:00
4 changed files with 1103 additions and 13 deletions

565
main.py
View File

@@ -4,13 +4,14 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import httpx
import asyncio
from typing import Optional, List, Union
from typing import Optional, List, Union, Dict, Any
import json
import os
from dotenv import load_dotenv
import discogs_client as discogs
from enum import Enum
from pydantic import BaseModel
from datetime import datetime
# Load environment variables
load_dotenv()
@@ -32,13 +33,37 @@ class MediaItem(BaseModel):
title: str
artist: Optional[str] = None
author: Optional[str] = None
year: str
year: Union[str, int, None] = None
isbn: Optional[str] = None
label: Optional[str] = None
format: Optional[str] = None
cover_url: Optional[str] = None
openlibrary_url: Optional[str] = None
discogs_url: Optional[str] = None
class Config:
extra = "ignore"
str_strip_whitespace = True
def __init__(self, **data):
# Convert year to string if it's not None
if data.get('year') is not None:
data['year'] = str(data['year'])
super().__init__(**data)
# InvenTree API Models
class StorageLocation(BaseModel):
pk: Optional[int] = None
name: str
description: Optional[str] = None
parent: Optional[int] = None
pathstring: Optional[str] = None
class CreateItemRequest(BaseModel):
media_item: MediaItem
storage_location_id: int
quantity: float = 1.0
notes: Optional[str] = None
app = FastAPI(
title="Media Inventory App",
@@ -54,6 +79,46 @@ templates = Jinja2Templates(directory="templates")
OPENLIBRARY_SEARCH_URL = "https://openlibrary.org/search.json"
DISCOGS_SEARCH_URL = "https://api.discogs.com/database/search"
# InvenTree API Functions
def get_inventree_config() -> Optional[Dict[str, Any]]:
"""Get InvenTree API configuration"""
api_url = os.getenv("INVENTREE_API_URL")
api_token = os.getenv("INVENTREE_API_TOKEN")
if not api_url or not api_token:
return None
# Ensure URL ends with /
if not api_url.endswith('/'):
api_url += '/'
return {
"api_url": api_url,
"api_token": api_token,
"timeout": int(os.getenv("INVENTREE_API_TIMEOUT", "30")),
"headers": {
"Authorization": f"Token {api_token}",
"Content-Type": "application/json"
}
}
async def test_inventree_connection() -> bool:
"""Test InvenTree API connection"""
config = get_inventree_config()
if not config:
return False
try:
async with httpx.AsyncClient(timeout=config["timeout"]) as client:
response = await client.get(
f"{config['api_url']}user/me/",
headers=config["headers"]
)
return response.status_code == 200
except Exception as e:
print(f"InvenTree API connection test failed: {e}")
return False
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
"""Serve the main search form"""
@@ -246,6 +311,502 @@ async def search_discogs(query: str, media_type: str, artist: Optional[str] = No
"discogs_url": "https://www.discogs.com/settings/developers"
}]
# InvenTree Inventory Management Endpoints
@app.get("/inventory", response_class=HTMLResponse)
async def inventory_home(request: Request):
"""Serve the inventory management page"""
config = get_inventree_config()
if not config:
return templates.TemplateResponse("inventory.html", {
"request": request,
"error": "InvenTree API not configured. Please check your .env file."
})
# Test connection and get locations
if not await test_inventree_connection():
return templates.TemplateResponse("inventory.html", {
"request": request,
"error": "Failed to connect to InvenTree API. Please check your configuration."
})
try:
# Get storage locations for the dropdown
async with httpx.AsyncClient(timeout=config["timeout"]) as client:
response = await client.get(
f"{config['api_url']}stock/location/",
headers=config["headers"]
)
if response.status_code == 200:
locations_data = response.json()
# Handle both paginated and direct list responses
if isinstance(locations_data, dict) and "results" in locations_data:
locations = locations_data.get("results", [])
elif isinstance(locations_data, list):
locations = locations_data
else:
locations = []
location_data = []
for loc in locations:
if isinstance(loc, dict):
location_data.append({
"pk": loc.get("pk"),
"name": loc.get("name", "Unknown"),
"pathstring": loc.get("pathstring", loc.get("name", "Unknown"))
})
return templates.TemplateResponse("inventory.html", {
"request": request,
"locations": location_data
})
else:
return templates.TemplateResponse("inventory.html", {
"request": request,
"error": f"Failed to fetch storage locations: HTTP {response.status_code}"
})
except Exception as e:
return templates.TemplateResponse("inventory.html", {
"request": request,
"error": f"Error connecting to InvenTree: {str(e)}"
})
@app.get("/api/storage-locations")
async def get_storage_locations():
"""Get all storage locations from InvenTree"""
config = get_inventree_config()
if not config:
return {"error": "InvenTree API not configured"}
try:
async with httpx.AsyncClient(timeout=config["timeout"]) as client:
response = await client.get(
f"{config['api_url']}stock/location/",
headers=config["headers"]
)
if response.status_code == 200:
data = response.json()
# Handle both paginated and direct list responses
if isinstance(data, dict) and "results" in data:
locations = data.get("results", [])
elif isinstance(data, list):
locations = data
else:
locations = []
return [
{
"pk": loc.get("pk"),
"name": loc.get("name"),
"description": loc.get("description"),
"pathstring": loc.get("pathstring")
}
for loc in locations if isinstance(loc, dict)
]
else:
return {"error": f"HTTP {response.status_code}: {response.text}"}
except Exception as e:
return {"error": str(e)}
@app.post("/api/storage-locations")
async def create_storage_location(location_data: dict = Body(...)):
"""Create a new storage location in InvenTree"""
config = get_inventree_config()
if not config:
return {"error": "InvenTree API not configured"}
try:
# Ensure description is not None - provide empty string if not provided
if "description" not in location_data or location_data["description"] is None:
location_data["description"] = ""
# Clean up the data to ensure it's properly formatted
clean_data = {
"name": location_data.get("name", ""),
"description": location_data.get("description", "")
}
# Add parent if provided
if "parent" in location_data and location_data["parent"]:
clean_data["parent"] = location_data["parent"]
async with httpx.AsyncClient(timeout=config["timeout"]) as client:
response = await client.post(
f"{config['api_url']}stock/location/",
headers=config["headers"],
json=clean_data
)
if response.status_code == 201:
location = response.json()
return {"success": True, "location": {"pk": location.get("pk"), "name": location.get("name")}}
else:
return {"error": f"HTTP {response.status_code}: {response.text}"}
except Exception as e:
return {"error": f"Error creating location: {str(e)}"}
@app.delete("/api/storage-locations/{location_id}")
async def delete_storage_location(location_id: int):
"""Delete a storage location if it's empty (has no stock items)"""
config = get_inventree_config()
if not config:
return {"error": "InvenTree API not configured"}
try:
async with httpx.AsyncClient(timeout=config["timeout"]) as client:
# First, check if the location has any stock items
stock_response = await client.get(
f"{config['api_url']}stock/",
headers=config["headers"],
params={"location": location_id}
)
if stock_response.status_code == 200:
stock_data = stock_response.json()
# Handle both paginated and direct list responses for stock items
if isinstance(stock_data, dict) and "results" in stock_data:
stock_items = stock_data.get("results", [])
elif isinstance(stock_data, list):
stock_items = stock_data
else:
stock_items = []
# Check if location has any stock items
if len(stock_items) > 0:
return {
"success": False,
"error": "Cannot delete location with stock items",
"message": f"Location contains {len(stock_items)} stock item(s). Move or remove items first."
}
else:
return {"error": f"Failed to check stock items: HTTP {stock_response.status_code}"}
# If location is empty, proceed with deletion
delete_response = await client.delete(
f"{config['api_url']}stock/location/{location_id}/",
headers=config["headers"]
)
if delete_response.status_code == 204:
return {"success": True, "message": "Storage location deleted successfully"}
else:
return {"error": f"Failed to delete location: HTTP {delete_response.status_code}: {delete_response.text}"}
except Exception as e:
return {"error": f"Error deleting location: {str(e)}"}
@app.post("/api/create-inventory-item")
async def create_inventory_item(create_request: CreateItemRequest):
"""Create a new inventory item from a media search result"""
config = get_inventree_config()
if not config:
return {"success": False, "error": "InvenTree API not configured"}
try:
print(f"Creating inventory item for: {create_request.media_item.title}")
# First, get or create the part category for media items
media_category_id = await get_or_create_media_category(config)
print(f"Media category ID: {media_category_id}")
# Create the part with required fields
part_data = {
"name": create_request.media_item.title,
"description": create_part_description(create_request.media_item) or "Media item",
"active": True,
"purchaseable": False,
"salable": False,
"trackable": False
}
# Add optional fields
keywords = create_part_keywords(create_request.media_item)
if keywords:
part_data["keywords"] = keywords
if media_category_id:
part_data["category"] = media_category_id
if create_request.media_item.openlibrary_url or create_request.media_item.discogs_url:
part_data["link"] = create_request.media_item.openlibrary_url or create_request.media_item.discogs_url
print(f"Creating part with data: {part_data}")
# Create the part using REST API
async with httpx.AsyncClient(timeout=config["timeout"]) as client:
part_response = await client.post(
f"{config['api_url']}part/",
headers=config["headers"],
json=part_data
)
if part_response.status_code != 201:
error_text = part_response.text
print(f"Part creation failed: HTTP {part_response.status_code} - {error_text}")
return {
"success": False,
"error": f"Part creation failed: HTTP {part_response.status_code}",
"message": f"Failed to create part: {error_text}"
}
part = part_response.json()
part_id = part.get("pk")
print(f"Part created successfully: {part_id}")
# Add custom parameters for books (ISBN and Year)
await add_part_parameters(client, config, part_id, create_request.media_item)
# Create the stock item
stock_data = {
"part": part_id,
"location": create_request.storage_location_id,
"quantity": create_request.quantity,
"notes": create_request.notes or f"Added from media search on {datetime.now().strftime('%Y-%m-%d')}"
}
print(f"Creating stock item with data: {stock_data}")
stock_response = await client.post(
f"{config['api_url']}stock/",
headers=config["headers"],
json=stock_data
)
if stock_response.status_code != 201:
error_text = stock_response.text
print(f"Stock item creation failed: HTTP {stock_response.status_code} - {error_text}")
return {
"success": False,
"error": f"Stock item creation failed: HTTP {stock_response.status_code}",
"message": f"Failed to create stock item: {error_text}"
}
stock_item = stock_response.json()
stock_id = stock_item.get("pk")
print(f"Stock item created successfully: {stock_id}")
return {
"success": True,
"part_id": part_id,
"stock_id": stock_id,
"message": f"Successfully added '{create_request.media_item.title}' to inventory"
}
except Exception as e:
print(f"Unexpected error in create_inventory_item: {e}")
return {
"success": False,
"error": str(e),
"message": f"Failed to add item to inventory: {str(e)}"
}
async def get_or_create_media_category(config: Dict[str, Any]) -> Optional[int]:
"""Get or create the 'Media' category for organizing media items"""
try:
async with httpx.AsyncClient(timeout=config["timeout"]) as client:
# Try to find existing media category
response = await client.get(
f"{config['api_url']}part/category/",
headers=config["headers"],
params={"name": "Media"}
)
if response.status_code == 200:
data = response.json()
categories = data.get("results", [])
if categories:
return categories[0].get("pk")
# Create media category if it doesn't exist
category_data = {
"name": "Media",
"description": "Books, vinyl records, CDs, and cassettes"
}
create_response = await client.post(
f"{config['api_url']}part/category/",
headers=config["headers"],
json=category_data
)
if create_response.status_code == 201:
category = create_response.json()
return category.get("pk")
except Exception as e:
print(f"Error with media category: {e}")
return None
def create_part_description(media_item: MediaItem) -> str:
"""Create a description for the part based on media item data"""
desc_parts = []
if media_item.artist:
desc_parts.append(f"Artist: {media_item.artist}")
elif media_item.author:
desc_parts.append(f"Author: {media_item.author}")
if media_item.year and media_item.year != "Unknown":
desc_parts.append(f"Year: {media_item.year}")
if media_item.format:
desc_parts.append(f"Format: {media_item.format}")
if media_item.label:
desc_parts.append(f"Label: {media_item.label}")
if media_item.isbn:
desc_parts.append(f"ISBN: {media_item.isbn}")
return " | ".join(desc_parts) if desc_parts else "Media item"
def create_part_keywords(media_item: MediaItem) -> str:
"""Create keywords for the part based on media item data"""
keywords = []
if media_item.artist:
keywords.append(media_item.artist)
if media_item.author:
keywords.append(media_item.author)
if media_item.format:
keywords.append(media_item.format)
if media_item.label:
keywords.append(media_item.label)
return ", ".join(keywords)
async def add_part_parameters(client: httpx.AsyncClient, config: Dict[str, Any], part_id: int, media_item: MediaItem):
"""Add custom parameters to a part based on media item data"""
try:
parameters_to_add = []
# Add ISBN for books
if media_item.isbn and media_item.isbn.strip():
parameters_to_add.append({
"name": "ISBN",
"value": media_item.isbn.strip()
})
# Add Year for all media types
if media_item.year and str(media_item.year).strip() and str(media_item.year) != "Unknown":
parameters_to_add.append({
"name": "Year",
"value": str(media_item.year).strip()
})
# Add Format for music items
if media_item.format and media_item.format.strip():
parameters_to_add.append({
"name": "Format",
"value": media_item.format.strip()
})
# Add Label for music items
if media_item.label and media_item.label.strip():
parameters_to_add.append({
"name": "Label",
"value": media_item.label.strip()
})
# Add Artist for music items
if media_item.artist and media_item.artist.strip():
parameters_to_add.append({
"name": "Artist",
"value": media_item.artist.strip()
})
# Add Author for books
if media_item.author and media_item.author.strip():
parameters_to_add.append({
"name": "Author",
"value": media_item.author.strip()
})
# Create each parameter
for param in parameters_to_add:
param_data = {
"part": part_id,
"template": None, # We'll create custom parameters without templates
"data": param["value"]
}
# First, try to find or create a parameter template
template_id = await get_or_create_parameter_template(client, config, param["name"])
if template_id:
param_data["template"] = template_id
print(f"Adding parameter {param['name']}: {param['value']} to part {part_id}")
param_response = await client.post(
f"{config['api_url']}part/parameter/",
headers=config["headers"],
json=param_data
)
if param_response.status_code == 201:
print(f"Successfully added parameter {param['name']}")
else:
print(f"Failed to add parameter {param['name']}: HTTP {param_response.status_code} - {param_response.text}")
except Exception as e:
print(f"Error adding part parameters: {e}")
async def get_or_create_parameter_template(client: httpx.AsyncClient, config: Dict[str, Any], param_name: str) -> Optional[int]:
"""Get or create a parameter template for the given parameter name"""
try:
# Try to find existing parameter template
response = await client.get(
f"{config['api_url']}part/parameter/template/",
headers=config["headers"],
params={"name": param_name}
)
if response.status_code == 200:
data = response.json()
# Handle both paginated and direct list responses
if isinstance(data, dict) and "results" in data:
templates = data.get("results", [])
elif isinstance(data, list):
templates = data
else:
templates = []
if templates:
return templates[0].get("pk")
# Create parameter template if it doesn't exist
template_data = {
"name": param_name,
"units": ""
}
create_response = await client.post(
f"{config['api_url']}part/parameter/template/",
headers=config["headers"],
json=template_data
)
if create_response.status_code == 201:
template = create_response.json()
print(f"Created parameter template: {param_name}")
return template.get("pk")
else:
print(f"Failed to create parameter template {param_name}: HTTP {create_response.status_code}")
except Exception as e:
print(f"Error with parameter template {param_name}: {e}")
return None
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -11,6 +11,19 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/">📚 Media Inventory</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="/">🔍 Search</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/inventory">📦 Inventory</a>
</li>
</ul>
</div>
</div>
</nav>

281
templates/inventory.html Normal file
View File

@@ -0,0 +1,281 @@
{% extends "base.html" %}
{% block title %}Inventory Management - Media Inventory App{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<h2>📦 Inventory Management</h2>
<p class="text-muted">Manage your InvenTree storage locations and inventory items</p>
{% if error %}
<div class="alert alert-danger" role="alert">
<h5>⚠️ Configuration Error</h5>
<p>{{ error }}</p>
<hr>
<p class="mb-0">
<strong>To configure InvenTree integration:</strong><br>
1. Set up your InvenTree instance<br>
2. Add your API credentials to the .env file<br>
3. Restart the application
</p>
</div>
{% else %}
<!-- Storage Locations Section -->
<div class="row mb-4">
<div class="col-md-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">📍 Storage Locations</h5>
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#createLocationModal">
Add Location
</button>
</div>
<div class="card-body">
{% if locations %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Path</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for location in locations %}
<tr id="location-row-{{ location.pk }}">
<td>{{ location.pk }}</td>
<td>{{ location.name }}</td>
<td><small class="text-muted">{{ location.pathstring }}</small></td>
<td>
<button class="btn btn-outline-danger btn-sm"
onclick="deleteLocation({{ location.pk }}, '{{ location.name }}')"
title="Delete empty location">
🗑️ Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<p class="text-muted">No storage locations found</p>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createLocationModal">
Create Your First Location
</button>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0"> How to Use</h5>
</div>
<div class="card-body">
<ol class="small">
<li><strong>Create Locations:</strong> Set up storage locations for your media</li>
<li><strong>Search Media:</strong> Use the search function to find items</li>
<li><strong>Add to Inventory:</strong> Click "Add to Inventory" on search results</li>
<li><strong>Delete Empty Locations:</strong> Remove unused locations with the delete button</li>
<li><strong>Manage:</strong> Use InvenTree web interface for advanced management</li>
</ol>
<hr>
<p class="small text-muted mb-0">
<strong>Connected to:</strong><br>
InvenTree API ✅
</p>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Create Location Modal -->
<div class="modal fade" id="createLocationModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"> Create Storage Location</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="createLocationForm">
<div class="mb-3">
<label for="locationName" class="form-label">Location Name *</label>
<input type="text" class="form-control" id="locationName" name="name" required
placeholder="e.g., Living Room Shelf, Bedroom Closet">
</div>
<div class="mb-3">
<label for="locationDescription" class="form-label">Description</label>
<textarea class="form-control" id="locationDescription" name="description" rows="3"
placeholder="Optional description of this storage location"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="createLocation()">Create Location</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteLocationModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">🗑️ Delete Storage Location</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<strong>⚠️ Warning:</strong> This action cannot be undone.
</div>
<p>Are you sure you want to delete the storage location <strong id="deleteLocationName"></strong>?</p>
<p class="text-muted small">
<strong>Note:</strong> You can only delete empty locations. If this location contains any stock items,
you'll need to move or remove them first.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">Delete Location</button>
</div>
</div>
</div>
</div>
<script>
async function createLocation() {
const form = document.getElementById('createLocationForm');
const formData = new FormData(form);
const data = Object.fromEntries(formData);
try {
const response = await fetch('/api/storage-locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
// Close modal and reload page
const modal = bootstrap.Modal.getInstance(document.getElementById('createLocationModal'));
modal.hide();
location.reload();
} else {
alert('Error creating location: ' + (result.error || 'Unknown error'));
}
} catch (error) {
alert('Error creating location: ' + error.message);
}
}
let locationToDelete = null;
function deleteLocation(locationId, locationName) {
// Store the location info for deletion
locationToDelete = { id: locationId, name: locationName };
// Update modal content
document.getElementById('deleteLocationName').textContent = locationName;
// Show the modal
const modal = new bootstrap.Modal(document.getElementById('deleteLocationModal'));
modal.show();
}
// Handle the actual deletion when confirmed
document.addEventListener('DOMContentLoaded', function() {
const confirmDeleteBtn = document.getElementById('confirmDeleteBtn');
if (confirmDeleteBtn) {
confirmDeleteBtn.addEventListener('click', async function() {
if (!locationToDelete) return;
// Disable button and show loading
confirmDeleteBtn.disabled = true;
confirmDeleteBtn.textContent = 'Deleting...';
try {
const response = await fetch(`/api/storage-locations/${locationToDelete.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
});
const result = await response.json();
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteLocationModal'));
modal.hide();
if (result.success) {
// Remove the row from the table
const row = document.getElementById(`location-row-${locationToDelete.id}`);
if (row) {
row.remove();
}
// Show success message
showAlert('success', result.message || 'Storage location deleted successfully');
} else {
// Show error message
showAlert('danger', result.message || result.error || 'Failed to delete location');
}
} catch (error) {
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteLocationModal'));
modal.hide();
showAlert('danger', 'Error deleting location: ' + error.message);
} finally {
// Reset button
confirmDeleteBtn.disabled = false;
confirmDeleteBtn.textContent = 'Delete Location';
locationToDelete = null;
}
});
}
});
function showAlert(type, message) {
// Create alert element
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
// Insert at the top of the content
const container = document.querySelector('.container-fluid, .container');
if (container) {
container.insertBefore(alertDiv, container.firstChild);
// Auto-dismiss after 5 seconds
setTimeout(() => {
if (alertDiv.parentNode) {
alertDiv.remove();
}
}, 5000);
}
}
</script>
{% endblock %}

View File

@@ -55,17 +55,24 @@
{% endif %}
<div class="mt-auto">
{% if media_type == 'book' and item.openlibrary_url %}
<a href="{{ item.openlibrary_url }}" target="_blank"
class="btn btn-primary btn-sm">
📚 View on OpenLibrary
</a>
{% elif media_type != 'book' and item.discogs_url %}
<a href="{{ item.discogs_url }}" target="_blank"
class="btn btn-primary btn-sm">
🎵 View on Discogs
</a>
{% endif %}
<div class="d-flex gap-2 flex-wrap">
{% if media_type == 'book' and item.openlibrary_url %}
<a href="{{ item.openlibrary_url }}" target="_blank"
class="btn btn-outline-primary btn-sm">
📚 View on OpenLibrary
</a>
{% elif media_type != 'book' and item.discogs_url %}
<a href="{{ item.discogs_url }}" target="_blank"
class="btn btn-outline-primary btn-sm">
🎵 View on Discogs
</a>
{% endif %}
<button class="btn btn-success btn-sm"
onclick="addToInventory({{ loop.index0 }})">
📦 Add to Inventory
</button>
</div>
</div>
</div>
</div>
@@ -81,4 +88,232 @@
{% endif %}
</div>
</div>
<!-- Add to Inventory Modal -->
<div class="modal fade" id="addToInventoryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">📦 Add to Inventory</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="inventoryItemDetails" class="mb-3">
<!-- Item details will be populated here -->
</div>
<form id="addToInventoryForm">
<div class="mb-3">
<label for="storageLocation" class="form-label">Storage Location *</label>
<select class="form-select" id="storageLocation" name="storage_location_id" required>
<option value="">Loading locations...</option>
</select>
<div class="form-text">
<a href="/inventory" target="_blank">Manage storage locations</a>
</div>
</div>
<div class="mb-3">
<label for="quantity" class="form-label">Quantity</label>
<input type="number" class="form-control" id="quantity" name="quantity"
value="1" min="1" step="1">
</div>
<div class="mb-3">
<label for="notes" class="form-label">Notes</label>
<textarea class="form-control" id="notes" name="notes" rows="2"
placeholder="Optional notes about this item"></textarea>
</div>
</form>
<div id="inventoryError" class="alert alert-danger d-none"></div>
<div id="inventorySuccess" class="alert alert-success d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" onclick="submitToInventory()" id="submitInventoryBtn">
📦 Add to Inventory
</button>
</div>
</div>
</div>
</div>
<script>
let currentMediaItem = null;
let storageLocations = [];
let searchResults = {{ results | tojson | safe }};
// Load storage locations when page loads
document.addEventListener('DOMContentLoaded', function() {
loadStorageLocations();
});
async function loadStorageLocations() {
try {
const response = await fetch('/api/storage-locations');
const data = await response.json();
if (data.error) {
console.error('Error loading storage locations:', data.error);
return;
}
storageLocations = data;
updateLocationDropdown();
} catch (error) {
console.error('Error loading storage locations:', error);
}
}
function updateLocationDropdown() {
const select = document.getElementById('storageLocation');
select.innerHTML = '';
if (storageLocations.length === 0) {
select.innerHTML = '<option value="">No storage locations found</option>';
return;
}
select.innerHTML = '<option value="">Select a storage location...</option>';
storageLocations.forEach(location => {
const option = document.createElement('option');
option.value = location.pk;
option.textContent = location.pathstring || location.name;
select.appendChild(option);
});
}
function addToInventory(itemIndex) {
console.log('addToInventory called with index:', itemIndex);
console.log('searchResults:', searchResults);
// Get the item data from the global search results array
if (!searchResults || itemIndex >= searchResults.length) {
console.error('Invalid item index or no search results');
showError('Invalid item selected');
return;
}
const itemData = searchResults[itemIndex];
console.log('Selected item:', itemData);
currentMediaItem = itemData;
// Populate item details
const detailsDiv = document.getElementById('inventoryItemDetails');
detailsDiv.innerHTML = `
<div class="card">
<div class="card-body">
<h6 class="card-title">${itemData.title}</h6>
<p class="card-text small">
${itemData.artist ? `<strong>Artist:</strong> ${itemData.artist}<br>` : ''}
${itemData.author ? `<strong>Author:</strong> ${itemData.author}<br>` : ''}
${itemData.year ? `<strong>Year:</strong> ${itemData.year}<br>` : ''}
${itemData.format ? `<strong>Format:</strong> ${itemData.format}` : ''}
</p>
</div>
</div>
`;
// Clear previous messages
document.getElementById('inventoryError').classList.add('d-none');
document.getElementById('inventorySuccess').classList.add('d-none');
// Reset form
document.getElementById('addToInventoryForm').reset();
document.getElementById('quantity').value = '1';
// Show modal
try {
const modalElement = document.getElementById('addToInventoryModal');
if (!modalElement) {
console.error('Modal element not found');
showError('Modal not found');
return;
}
const modal = new bootstrap.Modal(modalElement);
modal.show();
console.log('Modal shown successfully');
} catch (error) {
console.error('Error showing modal:', error);
showError('Error opening inventory modal');
}
}
async function submitToInventory() {
const form = document.getElementById('addToInventoryForm');
const formData = new FormData(form);
const submitData = {
media_item: currentMediaItem,
storage_location_id: parseInt(formData.get('storage_location_id')),
quantity: parseFloat(formData.get('quantity')),
notes: formData.get('notes') || null
};
// Validate required fields
if (!submitData.storage_location_id) {
showError('Please select a storage location');
return;
}
// Disable submit button
const submitBtn = document.getElementById('submitInventoryBtn');
const originalText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = 'Adding...';
try {
const response = await fetch('/api/create-inventory-item', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(submitData)
});
const result = await response.json();
if (result.success) {
showSuccess(result.message);
// Close modal after 2 seconds
setTimeout(() => {
const modal = bootstrap.Modal.getInstance(document.getElementById('addToInventoryModal'));
modal.hide();
}, 2000);
} else {
showError(result.message || result.error || 'Failed to add item to inventory');
}
} catch (error) {
showError('Error adding item to inventory: ' + error.message);
} finally {
// Re-enable submit button
submitBtn.disabled = false;
submitBtn.textContent = originalText;
}
}
function showError(message) {
const errorDiv = document.getElementById('inventoryError');
if (errorDiv) {
errorDiv.textContent = message;
errorDiv.classList.remove('d-none');
const successDiv = document.getElementById('inventorySuccess');
if (successDiv) {
successDiv.classList.add('d-none');
}
} else {
// Fallback to alert if modal error div not found
alert('Error: ' + message);
}
}
function showSuccess(message) {
const successDiv = document.getElementById('inventorySuccess');
successDiv.textContent = message;
successDiv.classList.remove('d-none');
document.getElementById('inventoryError').classList.add('d-none');
}
</script>
{% endblock %}