inventory management add/del
This commit is contained in:
438
main.py
438
main.py
@@ -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,375 @@ 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}")
|
||||
|
||||
# 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)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
@@ -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
281
templates/inventory.html
Normal 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 %}
|
@@ -55,17 +55,25 @@
|
||||
{% 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 }})"
|
||||
data-item="{{ item | tojson | e }}">
|
||||
📦 Add to Inventory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,4 +89,203 @@
|
||||
{% 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 = [];
|
||||
|
||||
// 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) {
|
||||
// Get the item data from the button's data attribute
|
||||
const buttons = document.querySelectorAll('[data-item]');
|
||||
const button = buttons[itemIndex];
|
||||
const itemData = JSON.parse(button.getAttribute('data-item'));
|
||||
|
||||
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
|
||||
const modal = new bootstrap.Modal(document.getElementById('addToInventoryModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
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');
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.classList.remove('d-none');
|
||||
document.getElementById('inventorySuccess').classList.add('d-none');
|
||||
}
|
||||
|
||||
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 %}
|
||||
|
Reference in New Issue
Block a user