Compare commits

...

14 commits

Author SHA1 Message Date
Xavier Fiechter
c7bd019f0e . 2025-12-23 22:44:31 +01:00
Xavier Fiechter
e4faeff358 . 2025-12-20 00:02:56 +01:00
Xavier Fiechter
16fecc6afa mig guide 2025-12-19 23:45:42 +01:00
Xavier Fiechter
b13e1846db . 2025-12-19 23:24:32 +01:00
Xavier Fiechter
748b9d6cd8 . 2025-12-19 21:02:54 +01:00
Xavier Fiechter
86f971b066 . 2025-12-19 20:59:52 +01:00
Xavier Fiechter
e9cd749151 . 2025-12-18 00:32:47 +01:00
Xavier Fiechter
f18bce10b0 . 2025-12-17 23:42:52 +01:00
Xavier Fiechter
82394dec51 wip 2025-12-12 23:50:35 +01:00
Xavier Fiechter
fff7ac548c wip 2025-12-12 23:47:04 +01:00
Xavier Fiechter
b5c0b7531f Upgrade app: new fields, debug admin label view, UI label edit, JSON validation, PEP8 cleanup 2025-11-22 10:12:16 +01:00
Xavier Fiechter
177a74aac1 added version ctrl for tx-math 2025-11-19 00:01:35 +01:00
Xavier Fiechter
f8008fd6f4 fixes #91 2025-11-18 23:54:24 +01:00
Xavier Fiechter
2f517a3ef8 . 2025-11-18 22:39:20 +01:00
47 changed files with 3805 additions and 285 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ labelbase.log
labelbase.log.*
bgt.log
db/
backup_*
*.pyc
__pycache__
django/importer/uploadeddata/*

View file

@ -0,0 +1,896 @@
# Labelbase Backup and Migration Guide
A comprehensive guide for safely backing up your Labelbase database and running Django migrations.
## Table of Contents
- [Why Backup?](#why-backup)
- [Quick Backup](#quick-backup)
- [Automated Backup Script](#automated-backup-script)
- [Running Migrations Safely](#running-migrations-safely)
- [Upgrading Labelbase](#upgrading-labelbase)
- [Restoring from Backup](#restoring-from-backup)
- [Scheduled Backups](#scheduled-backups)
- [Best Practices](#best-practices)
---
## Why Backup?
**Always backup before running migrations!** Migrations can:
- Alter database table structures in irreversible ways
- Delete data if there are bugs in the migration code
- Fail mid-execution, leaving your database inconsistent
- Introduce conflicts with existing data
A backup takes 30 seconds. Recovery without one could take hours or days.
---
## Quick Backup
From your Labelbase directory:
```bash
# Navigate to Labelbase directory
cd Labelbase
# Source environment variables
source exports.sh
# Create backup with timestamp
docker-compose exec -T labelbase_mysql mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" labelbase > backup_$(date +%Y%m%d_%H%M%S).sql
```
This creates a backup file like `backup_20250119_143022.sql`.
### Backup config.ini (Important!)
The `config.ini` file contains encryption keys and other critical settings. Always back it up too:
```bash
# Navigate to Labelbase directory
cd Labelbase
# Source environment variables
source exports.sh
# Create backup with timestamp
docker-compose exec -T labelbase_django cat /app/config.ini > backup_$(date +%Y%m%d_%H%M%S)_config.ini
```
This creates a backup file like `backup_20250119_143022_config.ini`.
---
## Automated Backup Script
Create a reusable backup script that handles everything automatically.
### Create the Script
Save this as `backup-labelbase.sh` in your Labelbase directory:
```bash
#!/bin/bash
# Labelbase Database Backup Script
# Usage: ./backup-labelbase.sh
# Configuration
LABELBASE_DIR="/path/to/Labelbase" # CHANGE THIS to your actual path
BACKUP_DIR="$LABELBASE_DIR/backups"
KEEP_BACKUPS=10 # Number of backups to keep
# Colors for output
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Change to Labelbase directory
cd "$LABELBASE_DIR" || exit 1
# Source environment variables for MySQL passwords
if [ ! -f "exports.sh" ]; then
echo -e "${RED}✗ Error: exports.sh not found!${NC}"
echo "Make sure you're in the Labelbase directory and exports.sh exists."
exit 1
fi
source exports.sh
# Check if MySQL password is set
if [ -z "$MYSQL_ROOT_PASSWORD" ]; then
echo -e "${RED}✗ Error: MYSQL_ROOT_PASSWORD not set!${NC}"
echo "Make sure exports.sh contains the MySQL password."
exit 1
fi
# Create backup directory if it doesn't exist
mkdir -p "$BACKUP_DIR"
# Generate backup filename with timestamp
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/labelbase_backup_$TIMESTAMP.sql"
CONFIG_BACKUP_FILE="$BACKUP_DIR/config_backup_$TIMESTAMP.ini"
echo -e "${YELLOW}Starting backup...${NC}"
echo "Database backup: $BACKUP_FILE"
echo "Config backup: $CONFIG_BACKUP_FILE"
# Backup config.ini first (contains encryption keys!)
echo "Backing up config.ini..."
docker-compose exec -T labelbase_django cat /app/config.ini > "$CONFIG_BACKUP_FILE"
if [ $? -eq 0 ] && [ -s "$CONFIG_BACKUP_FILE" ]; then
echo -e "${GREEN}✓ Config backup successful${NC}"
else
echo -e "${YELLOW}⚠ Config backup failed or file is empty${NC}"
fi
# Create database backup
docker-compose exec -T labelbase_mysql mysqldump \
-u root \
-p"${MYSQL_ROOT_PASSWORD}" \
--single-transaction \
--quick \
--lock-tables=false \
labelbase > "$BACKUP_FILE"
# Check if backup was successful
if [ $? -eq 0 ] && [ -s "$BACKUP_FILE" ]; then
echo -e "${GREEN}✓ Backup successful!${NC}"
# Get file size
SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
echo "Backup size: $SIZE"
# Compress backup to save space
echo "Compressing backup..."
gzip "$BACKUP_FILE"
if [ $? -eq 0 ]; then
COMPRESSED_SIZE=$(du -h "${BACKUP_FILE}.gz" | cut -f1)
echo -e "${GREEN}✓ Compressed to: $COMPRESSED_SIZE${NC}"
echo "Backup location: ${BACKUP_FILE}.gz"
else
echo -e "${YELLOW}⚠ Compression failed, keeping uncompressed backup${NC}"
fi
# Clean up old backups (keep only last N backups)
echo "Cleaning up old backups (keeping last $KEEP_BACKUPS)..."
BACKUP_COUNT=$(ls -1 "$BACKUP_DIR"/labelbase_backup_*.sql.gz 2>/dev/null | wc -l)
if [ "$BACKUP_COUNT" -gt "$KEEP_BACKUPS" ]; then
ls -t "$BACKUP_DIR"/labelbase_backup_*.sql.gz | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm
echo -e "${GREEN}✓ Cleaned up old backups${NC}"
else
echo "No cleanup needed ($BACKUP_COUNT backups exist)"
fi
echo ""
echo -e "${GREEN}=== Backup Complete ===${NC}"
echo "Database: ${BACKUP_FILE}.gz"
echo "Config: ${CONFIG_BACKUP_FILE}"
else
echo -e "${RED}✗ Backup failed!${NC}"
# Remove empty or failed backup file
[ -f "$BACKUP_FILE" ] && rm "$BACKUP_FILE"
echo "Troubleshooting:"
echo "1. Check if MySQL container is running: docker-compose ps"
echo "2. Check MySQL logs: docker-compose logs labelbase_mysql"
echo "3. Verify password in exports.sh"
exit 1
fi
```
### Make Script Executable
```bash
chmod +x backup-labelbase.sh
```
### Edit Configuration
Open `backup-labelbase.sh` and change this line to your actual Labelbase path:
```bash
LABELBASE_DIR="/path/to/Labelbase" # CHANGE THIS!
```
For example:
```bash
LABELBASE_DIR="/root/Labelbase"
# or
LABELBASE_DIR="/home/username/Labelbase"
```
### Run the Backup
```bash
./backup-labelbase.sh
```
You'll see output like:
```
Starting backup...
Backup file: /path/to/Labelbase/backups/labelbase_backup_20250119_143022.sql
✓ Backup successful!
Backup size: 15M
Compressing backup...
✓ Compressed to: 3.2M
Backup location: /path/to/Labelbase/backups/labelbase_backup_20250119_143022.sql.gz
Cleaning up old backups (keeping last 10)...
No cleanup needed (3 backups exist)
=== Backup Complete ===
Location: /path/to/Labelbase/backups/labelbase_backup_20250119_143022.sql.gz
```
---
## Running Migrations Safely
**Always follow this order:**
### Step 1: Create a Backup
```bash
./backup-labelbase.sh
```
### Step 2: Check Migration Status
```bash
source exports.sh
docker-compose exec labelbase_django python manage.py showmigrations
```
This shows which migrations are applied (marked with `[X]`) and pending (marked with `[ ]`).
### Step 3: Review Pending Migrations
Look for any unapplied migrations. If you see pending migrations for critical apps, review them carefully.
### Step 4: Apply Migrations
```bash
# If you've modified models, create new migrations first
docker-compose exec labelbase_django python manage.py makemigrations
# Apply all pending migrations
docker-compose exec labelbase_django python manage.py migrate
```
### Step 5: Verify Application
After migrations complete:
1. Check for any error messages
2. Visit your Labelbase site
3. Test critical functionality
4. Check Django logs: `docker-compose logs labelbase_django`
### Step 6: If Something Goes Wrong
If migrations fail or break functionality, restore from backup (see below).
---
## Upgrading Labelbase
When new versions of Labelbase are released, follow this workflow to safely upgrade.
### Complete Upgrade Workflow
**Step 1: Backup First (Critical!)**
```bash
cd Labelbase
./backup-labelbase.sh
```
**Step 2: Pull Latest Code**
```bash
git pull origin master
```
**Step 3: Rebuild Containers (if needed)**
If dependencies or Docker configuration changed:
```bash
source exports.sh && docker-compose up --build -d
```
Or use the main script:
```bash
source exports.sh && ./build-and-run-labelbase.sh
```
**⚠️ IMPORTANT**: These commands are SAFE - they rebuild containers but preserve your data in Docker volumes. Your database and uploaded files are NOT deleted.
**❌ DANGER ZONE - Commands that DELETE data:**
```bash
# NEVER run these unless you want to lose ALL data:
docker-compose down -v # The -v flag deletes volumes = data loss!
docker volume prune # Deletes unused volumes
docker system prune -a # Nuclear option - deletes everything
```
**Step 4: Apply Migrations and collect static files **
```bash
source exports.sh
docker-compose exec labelbase_django python manage.py showmigrations
docker-compose exec labelbase_django python manage.py migrate
docker-compose exec labelbase_django python manage.py collectstatic --noinput
```
**Step 5: Restart Services**
```bash
docker-compose restart labelbase_django
```
**Step 6: Verify Everything Works**
1. Visit your Labelbase site
2. Test critical functionality
3. Check logs: `docker-compose logs -f labelbase_django`
### Quick Upgrade Script
Create `update-and-migrate.sh` for a streamlined upgrade process:
```bash
#!/bin/bash
# Labelbase Quick Update & Migration Script
# Usage: source exports.sh && ./update-and-migrate.sh
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${YELLOW}=== Labelbase Update & Migrate ===${NC}"
# Check if we're in the right directory
if [ ! -f "docker-compose.yml" ]; then
echo -e "${RED}Error: Not in Labelbase directory (docker-compose.yml not found)${NC}"
exit 1
fi
# Check env vars
if [[ -z "${MYSQL_ROOT_PASSWORD}" ]]; then
echo -e "${RED}Error: Run 'source exports.sh' first!${NC}"
exit 1
fi
# Step 1: Backup
echo -e "${YELLOW}Step 1: Creating backup...${NC}"
if [ -f "backup-labelbase.sh" ]; then
./backup-labelbase.sh
if [ $? -ne 0 ]; then
echo -e "${RED}Backup failed! Aborting upgrade.${NC}"
exit 1
fi
else
echo -e "${YELLOW}⚠ Warning: backup-labelbase.sh not found, skipping backup${NC}"
read -p "Continue without backup? (yes/no): " -r
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
echo "Upgrade cancelled."
exit 0
fi
fi
# Step 2: Pull latest code
echo ""
echo -e "${YELLOW}Step 2: Pulling latest changes...${NC}"
git pull origin master
if [ $? -ne 0 ]; then
echo -e "${RED}Git pull failed!${NC}"
exit 1
fi
# Step 3: Check for pending migrations
echo ""
echo -e "${YELLOW}Step 3: Checking for migrations...${NC}"
PENDING=$(docker-compose exec -T labelbase_django python manage.py showmigrations --plan 2>/dev/null | grep "\[ \]" | wc -l)
if [ $PENDING -gt 0 ]; then
echo -e "${YELLOW}Found $PENDING pending migration(s)${NC}"
# Show what will be migrated
echo "Pending migrations:"
docker-compose exec -T labelbase_django python manage.py showmigrations | grep "\[ \]"
echo ""
read -p "Apply migrations now? (y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Applying migrations..."
docker-compose exec -T labelbase_django python manage.py migrate --noinput
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ Migrations applied successfully${NC}"
else
echo -e "${RED}✗ Migrations failed!${NC}"
echo "Check logs: docker-compose logs labelbase_django"
exit 1
fi
else
echo -e "${YELLOW}⚠ Skipping migrations${NC}"
echo "Run manually later: docker-compose exec labelbase_django python manage.py migrate"
fi
else
echo -e "${GREEN}✓ No pending migrations${NC}"
fi
# Step 4: Restart Django
echo ""
echo -e "${YELLOW}Step 4: Restarting Django...${NC}"
docker-compose restart labelbase_django
echo ""
echo -e "${GREEN}=== Update Complete! ===${NC}"
echo "Check logs: docker-compose logs -f labelbase_django"
echo "Visit your site to verify everything works"
```
Make it executable:
```bash
chmod +x update-and-migrate.sh
```
### Using the Quick Upgrade Script
```bash
# Navigate to Labelbase
cd Labelbase
# Source environment and run update
source exports.sh && ./update-and-migrate.sh
```
The script will:
1. ✓ Create automatic backup
2. ✓ Pull latest code from git
3. ✓ Detect pending migrations
4. ✓ Ask for confirmation before applying
5. ✓ Restart services
6. ✓ Provide verification steps
### When to Rebuild vs. Restart
**Just restart** (`docker-compose restart`) when:
- Only Django code changed (Python files)
- No dependency updates
- No Dockerfile changes
- Fastest option
**Full rebuild** (`docker-compose up --build -d`) when:
- requirements.txt changed
- Dockerfile modified
- New system packages needed
- Docker configuration changed
**Data Safety Note**: Both `restart` and `--build` are SAFE - they preserve your data. Docker stores your database and files in **volumes** that persist across rebuilds.
If unsure, rebuild - it's safer and only takes a minute longer.
### What Actually Deletes Data
Only these commands delete data (requires `-v` flag):
```bash
# DANGER: This deletes ALL data including database!
docker-compose down -v
# To safely stop without deleting data, use:
docker-compose down # Safe - keeps volumes
docker-compose stop # Safe - just stops containers
```
**Rule of thumb**: If you see `-v` flag, your data is at risk!
### Rollback After Failed Upgrade
If something goes wrong:
```bash
# 1. Stop services
docker-compose down
# 2. Restore previous code
git reset --hard HEAD~1
# 3. Restore database
./restore-labelbase.sh backups/labelbase_backup_TIMESTAMP.sql.gz
# 4. Restart
source exports.sh && docker-compose up -d
```
---
## Restoring from Backup
If something goes wrong, you can restore your database from a backup.
### Quick Restore
```bash
# Navigate to Labelbase directory
cd Labelbase
# Source environment variables
source exports.sh
# Stop Django to prevent conflicts
docker-compose stop labelbase_django
# Decompress and restore backup
gunzip -c backups/labelbase_backup_20250119_143022.sql.gz | \
docker-compose exec -T labelbase_mysql mysql -u root -p"${MYSQL_ROOT_PASSWORD}" labelbase
# Restart all services
docker-compose up -d
# Check logs
docker-compose logs -f labelbase_django
```
### Restore Script (Optional)
Create `restore-labelbase.sh`:
```bash
#!/bin/bash
# Labelbase Database Restore Script
# Usage: ./restore-labelbase.sh <backup-file>
LABELBASE_DIR="/path/to/Labelbase" # CHANGE THIS
BACKUP_FILE="$1"
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Check if backup file provided
if [ -z "$BACKUP_FILE" ]; then
echo -e "${RED}✗ Error: No backup file specified${NC}"
echo "Usage: ./restore-labelbase.sh <backup-file>"
echo ""
echo "Available backups:"
ls -lh "$LABELBASE_DIR/backups/"*.sql.gz 2>/dev/null || echo "No backups found"
exit 1
fi
# Check if backup file exists
if [ ! -f "$BACKUP_FILE" ]; then
echo -e "${RED}✗ Error: Backup file not found: $BACKUP_FILE${NC}"
exit 1
fi
# Change to Labelbase directory
cd "$LABELBASE_DIR" || exit 1
# Source environment variables
source exports.sh
echo -e "${YELLOW}⚠ WARNING: This will overwrite your current database!${NC}"
echo "Backup file: $BACKUP_FILE"
read -p "Are you sure you want to continue? (yes/no): " -r
echo
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
echo "Restore cancelled."
exit 0
fi
echo "Stopping Django container..."
docker-compose stop labelbase_django
echo "Restoring database..."
# Check if file is compressed
if [[ "$BACKUP_FILE" == *.gz ]]; then
gunzip -c "$BACKUP_FILE" | \
docker-compose exec -T labelbase_mysql mysql -u root -p"${MYSQL_ROOT_PASSWORD}" labelbase
else
cat "$BACKUP_FILE" | \
docker-compose exec -T labelbase_mysql mysql -u root -p"${MYSQL_ROOT_PASSWORD}" labelbase
fi
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ Database restored successfully${NC}"
else
echo -e "${RED}✗ Restore failed${NC}"
exit 1
fi
echo "Restarting services..."
docker-compose up -d
# Optional: Restore config.ini if you have a backup from the same time
# CONFIG_FILE="${BACKUP_FILE%_backup_*}_config_backup_${BACKUP_FILE##*_backup_}"
# CONFIG_FILE="${CONFIG_FILE%.sql.gz}.ini"
# if [ -f "$CONFIG_FILE" ]; then
# echo "Found config backup: $CONFIG_FILE"
# read -p "Restore config.ini too? (y/n) " -n 1 -r
# echo
# if [[ $REPLY =~ ^[Yy]$ ]]; then
# cat "$CONFIG_FILE" | docker-compose exec -T labelbase_django bash -c "cat > /app/config.ini"
# echo -e "${GREEN}✓ Config restored${NC}"
# docker-compose restart labelbase_django
# fi
# fi
echo ""
echo -e "${GREEN}=== Restore Complete ===${NC}"
echo "Check logs with: docker-compose logs -f labelbase_django"
```
Make executable and configure:
```bash
chmod +x restore-labelbase.sh
# Edit LABELBASE_DIR in the script
```
Usage:
```bash
./restore-labelbase.sh backups/labelbase_backup_20250119_143022.sql.gz
```
---
## Scheduled Backups
### Using Cron (Linux/Unix)
Automate daily backups at 2 AM:
```bash
# Edit crontab
crontab -e
# Add this line (adjust path to your Labelbase directory)
0 2 * * * cd /path/to/Labelbase && ./backup-labelbase.sh >> /var/log/labelbase-backup.log 2>&1
```
This runs the backup script daily at 2:00 AM and logs output to `/var/log/labelbase-backup.log`.
### Verify Cron Job
```bash
# List current cron jobs
crontab -l
# Check backup log
tail -f /var/log/labelbase-backup.log
```
### Alternative: Weekly Backups
```bash
# Every Sunday at 3 AM
0 3 * * 0 cd /path/to/Labelbase && ./backup-labelbase.sh >> /var/log/labelbase-backup.log 2>&1
```
---
## Best Practices
### Before Migrations
1. ✓ **Always create a backup first**
2. ✓ Review what migrations will be applied
3. ✓ Have a rollback plan ready
4. ✓ Test migrations on a development copy if possible
5. ✓ Schedule migrations during low-traffic periods
### Backup Storage
1. ✓ Keep backups in multiple locations
2. ✓ Regularly test your restore process
3. ✓ Keep at least 7-10 recent backups
4. ✓ Store critical backups off-server (external drive, cloud storage)
5. ✓ Monitor backup script success/failure
### Security
1. ✓ Protect `exports.sh` - it contains database passwords
2. ✓ Secure backup files - they contain all your data
3. ✓ Protect `config.ini` backups - they contain encryption keys
4. ✓ Use appropriate file permissions:
```bash
chmod 600 exports.sh
chmod 700 backups/
chmod 600 backups/*.sql.gz
chmod 600 backups/*_config.ini
```
### Regular Maintenance
1. ✓ Run backups before any system updates
2. ✓ Test restore process quarterly
3. ✓ Monitor backup file sizes (unexpected changes may indicate issues)
4. ✓ Keep backup logs for troubleshooting
### Docker Data Safety
1. ✓ **SAFE commands** (preserve data):
- `docker-compose up --build -d` - Rebuild containers
- `docker-compose restart` - Restart services
- `docker-compose down` - Stop without deleting volumes
- `docker-compose stop` - Pause containers
2. ✓ **DANGEROUS commands** (delete data):
- `docker-compose down -v` - ⚠️ Deletes ALL volumes/data
- `docker volume prune` - ⚠️ Removes unused volumes
- `docker system prune -a` - ⚠️ Nuclear option
3. ✓ **Remember**: The `-v` flag means "delete volumes" = data loss!
---
## Troubleshooting
### "MYSQL_ROOT_PASSWORD not set"
**Cause**: `exports.sh` not sourced or doesn't contain password
**Solution**:
```bash
# Check if exports.sh exists
ls -la exports.sh
# Source it
source exports.sh
# Verify password is set
echo $MYSQL_ROOT_PASSWORD
```
### "Access denied for user 'root'"
**Cause**: Wrong password in `exports.sh`
**Solution**:
1. Check MySQL container logs: `docker-compose logs labelbase_mysql`
2. Verify password in `exports.sh` matches what MySQL expects
3. If lost, you may need to reset MySQL root password
### Backup File is Empty or Very Small
**Cause**: MySQL container not running or database empty
**Solution**:
```bash
# Check container status
docker-compose ps
# Check MySQL logs
docker-compose logs labelbase_mysql
# Verify database exists
docker-compose exec labelbase_mysql mysql -u root -p -e "SHOW DATABASES;"
```
### Restore Fails with "Unknown Database"
**Cause**: Database doesn't exist in MySQL
**Solution**:
```bash
# Create database first
docker-compose exec labelbase_mysql mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS labelbase;"
# Then restore
./restore-labelbase.sh backups/your_backup.sql.gz
```
---
## Complete Safe Migration Workflow
Here's the complete workflow combining backup and migration:
```bash
# 1. Navigate to Labelbase
cd Labelbase
# 2. Create backup
./backup-labelbase.sh
# 3. Source environment
source exports.sh
# 4. Check what migrations will run
docker-compose exec labelbase_django python manage.py showmigrations
# 5. Apply migrations
docker-compose exec labelbase_django python manage.py makemigrations
docker-compose exec labelbase_django python manage.py migrate
# 6. Check for errors
docker-compose logs labelbase_django | tail -50
# 7. Test your application
# Visit site and verify functionality
# 8. If problems occur, restore:
# ./restore-labelbase.sh backups/labelbase_backup_TIMESTAMP.sql.gz
```
---
## Quick Command Reference
```bash
# Quick upgrade (recommended)
source exports.sh && ./update-and-migrate.sh
# Manual upgrade workflow
./backup-labelbase.sh
git pull origin master
source exports.sh && docker-compose up --build -d
docker-compose exec labelbase_django python manage.py migrate
docker-compose restart labelbase_django
# Create backup (database + config.ini)
./backup-labelbase.sh
# Manual database backup
docker-compose exec -T labelbase_mysql mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" labelbase > backup_$(date +%Y%m%d_%H%M%S).sql
# Manual config.ini backup
docker-compose exec -T labelbase_django cat /app/config.ini > backup_$(date +%Y%m%d_%H%M%S)_config.ini
# List backups
ls -lh backups/
# Check migration status
docker-compose exec labelbase_django python manage.py showmigrations
# Apply migrations
docker-compose exec labelbase_django python manage.py migrate
# Restore backup
./restore-labelbase.sh backups/labelbase_backup_20250119_143022.sql.gz
# View recent Django logs
docker-compose logs labelbase_django | tail -100
# Access MySQL directly
docker-compose exec labelbase_mysql mysql -u root -p labelbase
```
---
## Additional Resources
- [Labelbase Development Guide](DEVELOPMENT_GUIDE.md)
- [Django Migrations Documentation](https://docs.djangoproject.com/en/stable/topics/migrations/)
- [mysqldump Documentation](https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html)
---
## Support
If you encounter issues:
1. Check the troubleshooting section above
2. Review Docker logs: `docker-compose logs -f`
3. Verify all services are running: `docker-compose ps`
4. Check Labelbase GitHub issues: https://github.com/Labelbase/Labelbase/issues
---
**Remember: A backup today saves recovery tomorrow. Always backup before migrations!**

357
BARE_METALL_INSTALL.md Normal file
View file

@ -0,0 +1,357 @@
# Labelbase Bare Metal Installation Guide
This guide will help you install and run Labelbase directly on macOS and Linux systems, based on the RaspiBlitz installation script.
## Prerequisites
### Common Requirements
- Git
- Python 3.8 or higher
- pip (Python package manager)
- virtualenv
- MySQL/MariaDB server
### System-Specific Requirements
#### macOS
```bash
# Install Homebrew if not already installed
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install required packages
brew install python3 mysql git
brew install pkg-config mysql-client
```
#### Linux (Ubuntu/Debian)
```bash
# Update package list
sudo apt update
# Install required packages
sudo apt install -y \
mariadb-server \
mariadb-client \
default-libmysqlclient-dev \
build-essential \
python3 \
python3-pip \
python3-venv \
git \
libpcre3-dev
```
#### Linux (CentOS/RHEL/Fedora)
```bash
# For CentOS/RHEL 8+
sudo dnf install -y \
mariadb-server \
mariadb-devel \
python3 \
python3-pip \
python3-virtualenv \
git \
gcc \
gcc-c++ \
make
# For older versions, use yum instead of dnf
```
## Installation Steps
### 1. Create Application User (Optional but Recommended)
#### macOS
```bash
# Create a new user (optional on macOS for development)
sudo dscl . -create /Users/labelbase
sudo dscl . -create /Users/labelbase UserShell /bin/bash
sudo dscl . -create /Users/labelbase RealName "Labelbase User"
sudo dscl . -create /Users/labelbase UniqueID 1001
sudo dscl . -create /Users/labelbase PrimaryGroupID 20
sudo dscl . -create /Users/labelbase NFSHomeDirectory /Users/labelbase
sudo createhomedir -c -u labelbase
```
#### Linux
```bash
# Create system user
sudo adduser --system --group --shell /bin/bash --home /home/labelbase labelbase
sudo -u labelbase cp -r /etc/skel/. /home/labelbase/
```
### 2. Start Database Service
#### macOS
```bash
# Start MySQL service
brew services start mysql
# Secure the installation
mysql_secure_installation
```
#### Linux
```bash
# Start MariaDB service
sudo systemctl enable mariadb
sudo systemctl start mariadb
# Secure the installation
sudo mysql_secure_installation
```
### 3. Download and Setup Labelbase
```bash
# Switch to labelbase user (if created) or use your regular user
# sudo su - labelbase # (if using dedicated user)
# Set variables
LABELBASE_HOME="$HOME" # or /home/labelbase if using dedicated user
LABELBASE_REPO="https://github.com/Labelbase/Labelbase/"
LABELBASE_VERSION="2.2.1"
# Clone the repository
git clone $LABELBASE_REPO $LABELBASE_HOME/labelbase
cd $LABELBASE_HOME/labelbase
# Checkout specific version
git checkout $LABELBASE_VERSION
# Create virtual environment
python3 -m venv $LABELBASE_HOME/ENV
# Activate virtual environment
source $LABELBASE_HOME/ENV/bin/activate
# Install Python dependencies
pip install --upgrade pip
pip install --no-cache-dir -r $LABELBASE_HOME/labelbase/django/requirements.txt
```
### 4. Database Configuration
```bash
# Generate a secure password
MYSQL_PASSWORD=$(openssl rand -base64 32 | tr -d '+/' | fold -w 32 | head -n 1)
# Create exports file
cat > $LABELBASE_HOME/exports.sh << EOF
export MYSQL_PASSWORD=$MYSQL_PASSWORD
export DATABASE_URL=mysql://ulabelbase:$MYSQL_PASSWORD@localhost:3306/labelbase
EOF
chmod 755 $LABELBASE_HOME/exports.sh
```
#### Create Database and User
##### macOS
```bash
# Connect to MySQL
mysql -u root -p
# In MySQL prompt:
CREATE DATABASE labelbase;
CREATE USER 'ulabelbase'@'localhost' IDENTIFIED BY 'YOUR_GENERATED_PASSWORD';
GRANT ALL PRIVILEGES ON labelbase.* TO 'ulabelbase'@'localhost';
FLUSH PRIVILEGES;
EXIT;
```
##### Linux
```bash
# Connect to MariaDB
sudo mysql
# In MariaDB prompt:
CREATE DATABASE labelbase;
CREATE USER 'ulabelbase'@'localhost' IDENTIFIED BY 'YOUR_GENERATED_PASSWORD';
GRANT ALL PRIVILEGES ON labelbase.* TO 'ulabelbase'@'localhost';
FLUSH PRIVILEGES;
EXIT;
```
**Note:** Replace `YOUR_GENERATED_PASSWORD` with the password generated in the previous step.
### 5. Django Setup
```bash
# Navigate to Django directory
cd $LABELBASE_HOME/labelbase/django
# Activate virtual environment and load environment variables
source $LABELBASE_HOME/ENV/bin/activate
source $LABELBASE_HOME/exports.sh
# Run Django migrations
python manage.py makemigrations --noinput
python manage.py migrate --noinput
python manage.py collectstatic --noinput
# Create a superuser (optional)
python manage.py createsuperuser
```
### 6. Running Labelbase
#### Development Mode
```bash
# Activate environment
source $LABELBASE_HOME/ENV/bin/activate
source $LABELBASE_HOME/exports.sh
# Navigate to Django directory
cd $LABELBASE_HOME/labelbase/django
# Run development server
python manage.py runserver 0.0.0.0:8089
# Access at: http://localhost:8089
```
#### Production Mode (using Gunicorn)
```bash
# Install Gunicorn if not already installed
pip install gunicorn
# Run with Gunicorn
source $LABELBASE_HOME/ENV/bin/activate
source $LABELBASE_HOME/exports.sh
cd $LABELBASE_HOME/labelbase/django
gunicorn labellabor.wsgi:application -b 0.0.0.0:8089 --reload
```
## Creating System Services (Optional)
### macOS (using LaunchAgent)
Create a plist file at `~/Library/LaunchAgents/com.labelbase.app.plist`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.labelbase.app</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>cd /Users/labelbase/labelbase/django && source /Users/labelbase/ENV/bin/activate && source /Users/labelbase/exports.sh && gunicorn labellabor.wsgi:application -b 0.0.0.0:8089</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/labelbase/labelbase.out</string>
<key>StandardErrorPath</key>
<string>/Users/labelbase/labelbase.err</string>
</dict>
</plist>
```
Load the service:
```bash
launchctl load ~/Library/LaunchAgents/com.labelbase.app.plist
```
### Linux (using systemd)
Create `/etc/systemd/system/labelbase.service`:
```ini
[Unit]
Description=Labelbase Application
After=mariadb.service
[Service]
Type=simple
User=labelbase
WorkingDirectory=/home/labelbase/labelbase/django
Environment="HOME_PATH=/home/labelbase"
ExecStart=/bin/bash -c 'source /home/labelbase/ENV/bin/activate && source /home/labelbase/exports.sh && gunicorn labellabor.wsgi:application -b 0.0.0.0:8089'
Restart=always
TimeoutSec=120
RestartSec=30
[Install]
WantedBy=multi-user.target
```
Enable and start the service:
```bash
sudo systemctl enable labelbase
sudo systemctl start labelbase
sudo systemctl status labelbase
```
## Troubleshooting
### Common Issues
1. **Database Connection Errors**
- Ensure MySQL/MariaDB is running
- Verify database credentials in `exports.sh`
- Check if the database user has proper permissions
2. **Python Package Issues**
- Make sure virtual environment is activated
- Try upgrading pip: `pip install --upgrade pip`
- Install packages one by one if requirements.txt fails
3. **Port Already in Use**
- Change the port in the run command: `gunicorn ... -b 0.0.0.0:8090`
- Find and kill processes using the port: `lsof -ti:8089 | xargs kill -9`
4. **Permission Errors**
- Ensure proper ownership of files: `sudo chown -R labelbase:labelbase /home/labelbase`
- Check file permissions: `chmod 755 /home/labelbase/exports.sh`
### Logs and Debugging
- **Development server logs**: Displayed in terminal
- **Gunicorn logs**: Use `--log-file` flag for logging
- **System service logs** (Linux): `sudo journalctl -u labelbase -f`
- **Database logs**: Check MySQL/MariaDB error logs
### Stopping the Application
```bash
# If running in development mode
Ctrl+C
# If running as system service
# macOS:
launchctl unload ~/Library/LaunchAgents/com.labelbase.app.plist
# Linux:
sudo systemctl stop labelbase
```
## Security Considerations
1. **Firewall Configuration**
- Only expose necessary ports
- Consider using a reverse proxy (nginx/apache) for production
2. **Database Security**
- Use strong passwords
- Limit database user permissions
- Consider encrypting database connections
3. **Application Security**
- Keep dependencies updated
- Use HTTPS in production
- Regular security updates
4. **File Permissions**
- Ensure proper file ownership and permissions
- Limit access to configuration files
This guide provides a foundation for running Labelbase on bare metal systems. Adjust paths, ports, and configurations according to your specific needs and security requirements.

312
DEVELOPMENT_GUIDE.md Normal file
View file

@ -0,0 +1,312 @@
# Labelbase Development Guide
Quick reference for working with and developing Labelbase in Docker.
## Initial Setup
### 1. Clone and Setup
```bash
git clone https://github.com/Labelbase/Labelbase/
cd Labelbase
```
### 2. Generate MySQL Passwords
```bash
./make-exports.sh
```
This creates `exports.sh` with random passwords. **Backup this file!**
### 3. Build and Run
```bash
source exports.sh && ./build-and-run-labelbase.sh
```
The `source exports.sh` loads the passwords into your shell, then the script uses them.
Access at: http://127.0.0.1:8080
---
## Daily Development Workflow
### Start/Stop Services
```bash
# Start (always source exports.sh first!)
source exports.sh && docker-compose up -d
# Stop
docker-compose down
# Rebuild and restart (after code changes)
source exports.sh && docker-compose up --build -d
# Or use the main script
source exports.sh && ./build-and-run-labelbase.sh
```
### View Logs
```bash
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f labelbase_django
docker-compose logs -f labelbase_mysql
docker-compose logs -f labelbase_nginx
# Search logs
docker-compose logs labelbase_django | grep -i error
```
### Access Container Shell
```bash
# Django container (most common)
docker-compose exec labelbase_django bash
# MySQL container
docker-compose exec labelbase_mysql bash
# Nginx container
docker-compose exec labelbase_nginx sh
```
### Django Management Commands
```bash
# From host
docker-compose exec labelbase_django python manage.py <command>
# Or from inside container
docker-compose exec labelbase_django bash
python manage.py makemigrations
python manage.py migrate
python manage.py createsuperuser
python manage.py shell
```
### Database Operations
```bash
# Access MySQL CLI
docker-compose exec labelbase_mysql mysql -u ulabelbase -p labelbase
# Backup database
docker-compose exec labelbase_mysql mysqldump -u root -p labelbase > backup.sql
# Restore database
docker-compose exec -T labelbase_mysql mysql -u root -p labelbase < backup.sql
```
---
## Common Tasks
### Reset config.ini (if passwords change)
```bash
docker-compose exec labelbase_django bash
rm /app/config.ini
python manage.py make_config
exit
docker-compose restart labelbase_django
```
### Clean Rebuild (fresh start, deletes volumes!!)
❌ DANGER ZONE
```bash
docker-compose down -v
source exports.sh && docker-compose up --build -d
```
⚠️ **Warning**: `-v` deletes volumes including database data!
### Check What Django Sees
```bash
# Check environment variables
docker-compose exec labelbase_django env | grep MYSQL
# Check config.ini
docker-compose exec labelbase_django cat /app/config.ini
# Check database connection
docker-compose exec labelbase_django python manage.py dbshell
```
### Update from Git
```bash
git pull origin master
source exports.sh && ./build-and-run-labelbase.sh
```
The script automatically detects updates and rebuilds if needed.
---
## Configuration Files
### exports.sh (IMPORTANT - backup this!)
```bash
#!/bin/bash
export MYSQL_ROOT_PASSWORD="your_password_here"
export MYSQL_PASSWORD="your_password_here"
```
- Generated by `./make-exports.sh`
- Contains MySQL passwords
- Source before running docker-compose: `source exports.sh`
- **Add to `.gitignore`** - contains secrets!
### config.ini (auto-generated, persisted)
- Located at `/app/config.ini` inside Django container
- Generated from environment variables on first run by `python manage.py make_config`
- **Not overwritten** on subsequent runs (preserves user settings)
- Force regenerate: `rm /app/config.ini` then restart
### docker-compose.yml
Uses environment variables like `${MYSQL_PASSWORD}` from your shell (after sourcing exports.sh).
---
## Troubleshooting
### "Access denied for user 'ulabelbase'@'localhost'"
**Cause**: Old `config.ini` with wrong password from previous build
**Solution**:
```bash
docker-compose exec labelbase_django bash
rm /app/config.ini
python manage.py make_config
exit
docker-compose restart labelbase_django
```
### "MYSQL_PASSWORD variable is not set" warnings
**Cause**: Forgot to source exports.sh
**Solution**: Always use `source exports.sh && docker-compose up`
These warnings appear at parse time but are harmless if you source exports.sh before running the command.
### "Can't connect to MySQL server"
**Cause**: Database not ready yet
**Solution**: Wait 15 seconds (run.sh has a built-in delay) or check logs:
```bash
docker-compose logs labelbase_mysql
```
### Django can't see code changes
- Volume mounting issue
- Solution: `docker-compose restart labelbase_django`
- Or use `--reload` in gunicorn (already enabled)
### Port 8080 already in use
```bash
# Find what's using it
lsof -i :8080
# Change port in docker-compose.yml
ports:
- "127.0.0.1:8081:8080" # Use 8081 instead
```
---
## Development Tips
### Live Code Reloading
Django container mounts `./django:/app`, so changes are live. Gunicorn runs with `--reload` flag.
### Static Files
After changing CSS/JS:
```bash
docker-compose exec labelbase_django python manage.py collectstatic --noinput
```
### Running Tests
```bash
docker-compose exec labelbase_django python manage.py test
```
### Python Dependencies
Add to `requirements.txt`, then:
```bash
docker-compose up --build -d
```
### Database Migrations
```bash
# Create migrations
docker-compose exec labelbase_django python manage.py makemigrations
# Apply migrations
docker-compose exec labelbase_django python manage.py migrate
# Show migration status
docker-compose exec labelbase_django python manage.py showmigrations
```
---
## Production Deployment
See main README for:
- Setting up nginx reverse proxy
- SSL certificates with certbot
- Domain configuration
- Firewall rules
---
## Quick Command Reference
```bash
# Build and run
source exports.sh && ./build-and-run-labelbase.sh
# Stop
docker-compose down
# Logs
docker-compose logs -f
# Shell
docker-compose exec labelbase_django bash
or
source exports.sh && docker-compose exec labelbase_django bash
# Reset everything (DANGER: deletes data!)
docker-compose down -v && source exports.sh && docker-compose up --build -d
# Reset config.ini (if passwords changed)
docker-compose exec labelbase_django rm /app/config.ini
docker-compose restart labelbase_django
```
---
## Environment Variables Reference
| Variable | Purpose | Default |
|----------|---------|---------|
| `MYSQL_ROOT_PASSWORD` | MySQL root password | (required) |
| `MYSQL_PASSWORD` | MySQL user password | (required) |
| `MYSQL_DATABASE` | Database name | `labelbase` |
| `MYSQL_USER` | Database user | `ulabelbase` |
| `MYSQL_HOST` | Database hostname | `labelbase_mysql` |
| `MYSQL_PORT` | Database port | `3306` |
Set in `exports.sh` file and load with `source exports.sh` before running docker-compose.
**Note I**: You may see warnings like "variable is not set" when running docker-compose commands. These appear at parse time but are harmless - the variables are properly set when you source exports.sh before the command.
**NOTE II**: Some of the variables are hard coded or may need to be changed in the docker-compose.yml file manually.
---
## Need Help?
- Check logs: `docker-compose logs -f`
- View settings: `docker-compose exec labelbase_django cat /app/config.ini`
- Check environment: `docker-compose exec labelbase_django env | grep MYSQL`
- Access Django shell: `docker-compose exec labelbase_django python manage.py shell`

View file

@ -11,7 +11,7 @@ RUN apt-get update && \
default-libmysqlclient-dev \
build-essential \
cron vim logrotate \
libpcre3-dev \
libpcre2-dev \
default-mysql-client \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --upgrade pip \

223
django/finances/tx_math.py Normal file
View file

@ -0,0 +1,223 @@
# Constants
P2PKH_IN_SIZE = 148
P2PKH_OUT_SIZE = 34
P2SH_OUT_SIZE = 32
P2SH_P2WPKH_OUT_SIZE = 32
P2SH_P2WSH_OUT_SIZE = 32
P2WPKH_OUT_SIZE = 31
P2WSH_OUT_SIZE = P2TR_OUT_SIZE = 43
PUBKEY_SIZE = 33
SIGNATURE_SIZE = 72
def get_size_of_var_int(length):
if length <= 252:
return 1
elif length <= 0xffff:
return 3
elif length <= 0xffffffff:
return 5
else:
return 9
def get_size_of_script_length_element(length):
if length < 75:
return 1
elif length <= 255:
return 2
elif length <= 65535:
return 3
elif length <= 4294967295:
return 5
else:
raise ValueError("Script too large")
def get_input_size(input_attr):
"""Return (base_size, witness_size) for a single input."""
script_type = input_attr['input_script']
m = input_attr.get('input_m', 0)
n = input_attr.get('input_n', 0)
if script_type == "P2PKH":
return P2PKH_IN_SIZE, 0
elif script_type == "P2SH":
# Redeem script: OP_m + n_pubkeys + OP_n + OP_CHECKMULTISIG
redeem_script_size = 1 + n * (1 + PUBKEY_SIZE) + 1 + 1
# scriptSig: OP_0 + m_sigs + push_opcode + redeem_script
script_sig_size = 1 + m * (1 + SIGNATURE_SIZE) + get_size_of_script_length_element(redeem_script_size) + redeem_script_size
input_base_size = 32 + 4 + get_size_of_var_int(script_sig_size) + script_sig_size + 4
return input_base_size, 0
elif script_type == "P2SH-P2WPKH":
input_base_size = 32 + 4 + 1 + 23 + 4
input_witness_size = 107
return input_base_size, input_witness_size
elif script_type == "P2WPKH":
input_base_size = 32 + 4 + 1 + 4
input_witness_size = 107
return input_base_size, input_witness_size
elif script_type == "P2WSH":
input_base_size = 32 + 4 + 1 + 4
witness_script_size = 1 + n * (1 + PUBKEY_SIZE) + 1 + 1
num_stack_items = 1 + m + 1 # OP_0 + m sigs + witness script
input_witness_size = (
get_size_of_var_int(num_stack_items) +
1 + # OP_0 length
m * (1 + SIGNATURE_SIZE) +
get_size_of_var_int(witness_script_size) +
witness_script_size
)
return input_base_size, input_witness_size
elif script_type == "P2TR":
input_base_size = 32 + 4 + 1 + 4
input_witness_size = 65
return input_base_size, input_witness_size
else:
raise ValueError(f"Unsupported input script type: {script_type}")
def calculate_transaction_size(inputs, output_counts):
"""
inputs: list of dicts with keys: input_script, input_m, input_n
output_counts: dict with counts per output type
"""
total_base = 0
total_witness = 0
# Total inputs / outputs
input_count = len(inputs)
output_count = sum(output_counts.values())
# Transaction overhead: version(4) + varints + locktime(4)
tx_base_size = 4 + get_size_of_var_int(input_count) + get_size_of_var_int(output_count) + 4
# Segwit marker + flag if any input is segwit
has_witness = any(inp['input_script'] in ["P2SH-P2WPKH", "P2WPKH", "P2WSH", "P2TR"] for inp in inputs)
if has_witness:
total_witness += 2 # marker + flag
for inp in inputs:
base, witness = get_input_size(inp)
total_base += base
total_witness += witness
# Sum output sizes
output_size = (P2PKH_OUT_SIZE * output_counts.get('p2pkh', 0) +
P2SH_OUT_SIZE * output_counts.get('p2sh', 0) +
P2SH_P2WPKH_OUT_SIZE * output_counts.get('p2sh_p2wpkh', 0) +
P2SH_P2WSH_OUT_SIZE * output_counts.get('p2sh_p2wsh', 0) +
P2WPKH_OUT_SIZE * output_counts.get('p2wpkh', 0) +
P2WSH_OUT_SIZE * output_counts.get('p2wsh', 0) +
P2TR_OUT_SIZE * output_counts.get('p2tr', 0))
# Total base size
tx_total_base_size = tx_base_size + total_base + output_size
# Transaction weight and vbytes
tx_weight = tx_total_base_size * 4 + total_witness
tx_vbytes = tx_weight / 4
# Raw bytes (base + witness discounted by 1/4)
tx_bytes = tx_total_base_size + total_witness / 4
return {
'txBytes': round(tx_bytes),
'txVBytes': round(tx_vbytes),
'txWeight': tx_weight
}
def calculate_fee(tx_vbytes, fee_rate_sats_per_vbyte):
"""
tx_vbytes: virtual size from calculate_transaction_size()
fee_rate_sats_per_vbyte: fee rate in sats per vbyte
"""
return round(tx_vbytes * fee_rate_sats_per_vbyte)
def run_tests():
fee_rate = 20 # sats per vbyte
test_cases = [
# 1. Single P2PKH input -> single P2PKH output
{
'inputs': [{'input_script': 'P2PKH'}],
'outputs': {'p2pkh': 1},
'description': 'Single P2PKH -> P2PKH'
},
# 2. Two P2WPKH inputs -> two P2WPKH outputs
{
'inputs': [{'input_script': 'P2WPKH'}, {'input_script': 'P2WPKH'}],
'outputs': {'p2wpkh': 2},
'description': 'Two P2WPKH -> Two P2WPKH'
},
# 3. Single P2SH 2-of-3 multisig input -> two P2PKH outputs
{
'inputs': [{'input_script': 'P2SH', 'input_m': 2, 'input_n': 3}],
'outputs': {'p2pkh': 2},
'description': 'P2SH 2-of-3 multisig -> 2x P2PKH'
},
# 4. Mixed inputs: P2PKH + P2WPKH + P2TR -> P2PKH + P2WPKH
{
'inputs': [
{'input_script': 'P2PKH'},
{'input_script': 'P2WPKH'},
{'input_script': 'P2TR'}
],
'outputs': {'p2pkh': 1, 'p2wpkh': 1},
'description': 'Mixed inputs -> mixed outputs'
},
# 5. Two P2WSH multisig inputs -> P2WSH outputs
{
'inputs': [
{'input_script': 'P2WSH', 'input_m': 2, 'input_n': 3},
{'input_script': 'P2WSH', 'input_m': 1, 'input_n': 2}
],
'outputs': {'p2wsh': 2},
'description': 'Two P2WSH -> Two P2WSH'
}
]
for idx, test in enumerate(test_cases, 1):
# Step 1: calculate size
tx_size = calculate_transaction_size(test['inputs'], test['outputs'])
# Step 2: calculate fee
fee_sats = calculate_fee(tx_size['txVBytes'], fee_rate)
print(f"Test {idx}: {test['description']}")
print(f" txBytes: {tx_size['txBytes']}, txVBytes: {tx_size['txVBytes']}, txWeight: {tx_size['txWeight']}")
print(f" Fee (@ {fee_rate} sats/vbyte): {fee_sats} sats\n")
# Example usage
if __name__ == "__main__":
inputs = [
{'input_script': 'P2PKH'},
{'input_script': 'P2WPKH'},
{'input_script': 'P2SH', 'input_m': 2, 'input_n': 3},
]
output_counts = {
'p2pkh': 1,
'p2sh': 0,
'p2sh_p2wpkh': 1,
'p2sh_p2wsh': 0,
'p2wpkh': 0,
'p2wsh': 0,
'p2tr': 0
}
tx_size = calculate_transaction_size(inputs, output_counts)
print(tx_size)
print("*"*80)
run_tests()

View file

@ -0,0 +1,48 @@
def run_tests():
test_cases = [
# 1. Single P2PKH input -> single P2PKH output
{
'inputs': [{'input_script': 'P2PKH'}],
'outputs': {'p2pkh': 1},
'description': 'Single P2PKH -> P2PKH'
},
# 2. Two P2WPKH inputs -> two P2WPKH outputs
{
'inputs': [{'input_script': 'P2WPKH'}, {'input_script': 'P2WPKH'}],
'outputs': {'p2wpkh': 2},
'description': 'Two P2WPKH -> Two P2WPKH'
},
# 3. Single P2SH 2-of-3 multisig input -> two P2PKH outputs
{
'inputs': [{'input_script': 'P2SH', 'input_m': 2, 'input_n': 3}],
'outputs': {'p2pkh': 2},
'description': 'P2SH 2-of-3 multisig -> 2x P2PKH'
},
# 4. Mixed inputs: P2PKH + P2WPKH + P2TR -> P2PKH + P2WPKH
{
'inputs': [
{'input_script': 'P2PKH'},
{'input_script': 'P2WPKH'},
{'input_script': 'P2TR'}
],
'outputs': {'p2pkh': 1, 'p2wpkh': 1},
'description': 'Mixed inputs -> mixed outputs'
},
# 5. Two P2WSH multisig inputs -> P2WSH outputs
{
'inputs': [
{'input_script': 'P2WSH', 'input_m': 2, 'input_n': 3},
{'input_script': 'P2WSH', 'input_m': 1, 'input_n': 2}
],
'outputs': {'p2wsh': 2},
'description': 'Two P2WSH -> Two P2WSH'
}
]
for idx, test in enumerate(test_cases, 1):
result = calculate_transaction_size(test['inputs'], test['outputs'])
print(f"Test {idx}: {test['description']}")
print(f" txBytes: {result['txBytes']}, txVBytes: {result['txVBytes']}, txWeight: {result['txWeight']}\n")
if __name__ == "__main__":
run_tests()

View file

@ -61,8 +61,6 @@ def import_samourai_labels(labelbase, content, passphrase):
logger.info(f"data: {data}")
version = data.get("version", 1)
payload = data.get("payload", "")
logger.info(f"version: {version}, payload {payload}, passphrase {passphrase}")
if payload:
if version in [1, "1"]:
decrypted_data = decrypt_v1(payload, passphrase)

View file

@ -27,6 +27,9 @@ def process_uploaded_data(uploaded_data_id, passphrase=None, loop=None):
if buf in EOLSTOP:
break
data = json.loads(buf)
logger.info(f"Parsed data: {data}")
data["labelbase"] = labelbase.id
serializer = LabelSerializer(data=data)
if serializer.is_valid():

View file

@ -1,6 +1,36 @@
from django.contrib import admin
from .models import Labelbase
from .models import Label
from django.conf import settings
admin.site.register(Labelbase)
admin.site.register(Label)
class LabelbaseAdmin(admin.ModelAdmin):
list_display = ['id', 'user', 'name', 'network', 'operation_mode']
list_filter = ['network', 'operation_mode']
search_fields = ['name', 'fingerprint']
class LabelAdmin(admin.ModelAdmin):
list_display = ['id', 'type', 'labelbase', 'label']
list_filter = ['type', 'labelbase__network']
search_fields = ['ref', 'label']
# Organize fields into logical sections
fieldsets = (
('Core BIP-329 Fields', {
'fields': ('labelbase', 'type', 'ref', 'label', 'origin', 'spendable')
}),
('Additional BIP-329 Fields', {
'fields': ('height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights'),
'classes': ('collapse',), # Make this section collapsible
}),
('Internal', {
'fields': ('type_ref_hash',),
'classes': ('collapse',),
}),
)
readonly_fields = ['type_ref_hash']
if settings.DEBUG:
admin.site.register(Labelbase, LabelbaseAdmin)
admin.site.register(Label, LabelAdmin)

View file

@ -0,0 +1,54 @@
# Generated by Django 3.2.25 on 2025-11-22 08:00
from django.db import migrations, models
import django_cryptography.fields
class Migration(migrations.Migration):
dependencies = [
('labelbase', '0011_alter_labelbase_operation_mode'),
]
operations = [
migrations.AddField(
model_name='label',
name='fee',
field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Transaction fee in satoshis (stored as string)', max_length=32, null=True)),
),
migrations.AddField(
model_name='label',
name='fmv',
field=django_cryptography.fields.encrypt(models.TextField(blank=True, help_text='Fair market value (JSON string)', null=True)),
),
migrations.AddField(
model_name='label',
name='height',
field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Block height where transaction was confirmed', max_length=16, null=True)),
),
migrations.AddField(
model_name='label',
name='heights',
field=django_cryptography.fields.encrypt(models.TextField(blank=True, help_text='Block heights for address activity (JSON array as string)', null=True)),
),
migrations.AddField(
model_name='label',
name='keypath',
field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Key derivation path (e.g., /1/123)', max_length=256, null=True)),
),
migrations.AddField(
model_name='label',
name='rate',
field=django_cryptography.fields.encrypt(models.TextField(blank=True, help_text='Exchange rate at transaction time (JSON string)', null=True)),
),
migrations.AddField(
model_name='label',
name='time',
field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='ISO-8601 timestamp of the block', max_length=64, null=True)),
),
migrations.AddField(
model_name='label',
name='value',
field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Transaction value in satoshis, signed (stored as string)', max_length=32, null=True)),
),
]

View file

@ -2,6 +2,7 @@ from django.db import models
from django.contrib.auth.models import User
from django.urls import reverse
from django_cryptography.fields import encrypt
from django.utils.safestring import mark_safe
from pymempool import MempoolAPI
@ -180,9 +181,77 @@ class Label(models.Model):
)
)
labelbase = models.ForeignKey(Labelbase, on_delete=models.CASCADE)
labelbase = models.ForeignKey(
Labelbase,
on_delete=models.CASCADE
)
type_ref_hash = models.CharField(max_length=64, blank=True)
type_ref_hash = models.CharField(
max_length=64,
blank=True)
# All additional fields (from the BIP-329 upgrade) encrypted for maximum privacy
height = encrypt(
models.CharField(
max_length=16,
null=True,
blank=True,
help_text="Block height where transaction was confirmed"
)
)
time = encrypt(
models.CharField(
max_length=64,
null=True,
blank=True,
help_text="ISO-8601 timestamp of the block"
)
)
fee = encrypt(
models.CharField(
max_length=32,
null=True,
blank=True,
help_text="Transaction fee in satoshis (stored as string)"
)
)
value = encrypt(
models.CharField(
max_length=32,
null=True,
blank=True,
help_text="Transaction value in satoshis, signed (stored as string)"
)
)
rate = encrypt(
models.TextField(
null=True,
blank=True,
help_text="Exchange rate at transaction time (JSON string)"
)
)
keypath = encrypt(
models.CharField(
max_length=256,
null=True,
blank=True,
help_text="Key derivation path (e.g., /1/123)"
)
)
fmv = encrypt(
models.TextField(
null=True,
blank=True,
help_text="Fair market value (JSON string)"
)
)
heights = encrypt(
models.TextField(
null=True,
blank=True,
help_text="Block heights for address activity (JSON array as string)"
)
)
def get_extracted_fiat_value(self):
return extract_fiat_value(self.label)
@ -230,3 +299,102 @@ class Label(models.Model):
except:
pass
return ""
def get_fee_health_status(self):
"""
Calculate fee health status for this label if it's a spendable unspent output.
"""
# Only calculate for spendable outputs
if self.type != self.TYPE_OUTPUT or not self.spendable:
return {
'status': None,
'fee_sats': None,
'value_sats': None,
'fee_percentage': None,
'threshold_healthy': None,
'threshold_warning': None,
'threshold_high': None
}
try:
value_sats = int(self.value) if self.value else None
except (ValueError, TypeError):
value_sats = None
if not value_sats or value_sats <= 0:
return {
'status': None,
'fee_sats': None,
'value_sats': value_sats,
'fee_percentage': None,
'threshold_healthy': None,
'threshold_warning': None,
'threshold_high': None
}
# Get user's fee rate from profile
user_fee_rate = self.labelbase.user.profile.my_fee # sats per vbyte
threshold_adjustment = self.labelbase.user.profile.my_fee_rate_threshold # percentage points
# Use P2WPKH as default - most common modern type
# Simple 1-in, 2-out transaction
from finances.tx_math import calculate_transaction_size, calculate_fee
inputs = [{'input_script': 'P2WPKH'}]
output_counts = {'p2wpkh': 2}
tx_size = calculate_transaction_size(inputs, output_counts)
fee_sats = calculate_fee(tx_size['txVBytes'], user_fee_rate)
# Calculate fee as percentage of output value
fee_percentage = (fee_sats / value_sats) * 100
# Define thresholds (base + user adjustment)
threshold_healthy = 1.0 + threshold_adjustment
threshold_warning = 3.0 + threshold_adjustment
# Determine status
if fee_percentage < threshold_healthy:
status = 'green'
elif fee_percentage < threshold_warning:
status = 'yellow'
else:
status = 'red'
return {
'status': status,
'fee_sats': fee_sats,
'value_sats': value_sats,
'fee_percentage': round(fee_percentage, 3),
'threshold_healthy': threshold_healthy,
'threshold_warning': threshold_warning,
'threshold_high': threshold_warning
}
@property
def get_fee_health_status_display(self):
"""
Returns text representation of fee health status for DataTables display.
"""
health = self.get_fee_health_status()
if not health['status']:
return ''
status_map = {
'green': '🟢',
'yellow': '🟡',
'red': '🔴'
}
# FIXME: escaping in data tables
#status_map = {
# 'green': '<span data-feather="circle-check" class="align-text-bottom"></span>',
# 'yellow': '<span data-feather="alert-circle" class="align-text-bottom"></span>',
# 'red': '<span color:red; data-feather="alert-triangle" class="align-text-bottom"></span>'
#}
emoji = status_map.get(health['status'], '')
return f"{emoji} {health['fee_percentage']}%"

View file

@ -1,8 +1,9 @@
import json
from rest_framework import serializers
from labelbase.models import Labelbase, Label
class LabelSerializer(serializers.ModelSerializer):
class LabelSerializer_v1(serializers.ModelSerializer):
class Meta:
model = Label
fields = [
@ -19,6 +20,169 @@ class LabelSerializer(serializers.ModelSerializer):
]
class LabelSerializer(serializers.ModelSerializer):
# Additional BIP-329 fields
height = serializers.IntegerField(required=False, allow_null=True)
time = serializers.CharField(required=False, allow_null=True, allow_blank=True)
fee = serializers.IntegerField(required=False, allow_null=True)
value = serializers.IntegerField(required=False, allow_null=True)
rate = serializers.JSONField(required=False, allow_null=True)
keypath = serializers.CharField(required=False, allow_null=True, allow_blank=True)
fmv = serializers.JSONField(required=False, allow_null=True)
heights = serializers.ListField(
child=serializers.IntegerField(),
required=False,
allow_null=True
)
class Meta:
model = Label
fields = [
"id",
"labelbase",
"type",
"ref",
"label",
"origin",
"spendable",
# Additional BIP-329 fields
"height",
"time",
"fee",
"value",
"rate",
"keypath",
"fmv",
"heights",
]
read_only_fields = [
"id",
]
def validate(self, data):
"""Validate BIP-329 field combinations based on type"""
label_type = data.get('type')
# Define valid fields per type (from BIP-329 spec)
valid_fields = {
'tx': {'height', 'time', 'fee', 'value', 'rate'},
'addr': {'keypath', 'heights'},
'pubkey': {'keypath'},
'input': {'keypath', 'value', 'fmv', 'height', 'time'},
'output': {'spendable', 'keypath', 'value', 'fmv', 'height', 'time'},
'xpub': set()
}
# Get allowed additional fields for this type
allowed = valid_fields.get(label_type, set())
# Check for invalid field combinations
additional_fields = {'height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights', 'spendable'}
for field in additional_fields:
if field in data and data[field] is not None:
# Allow origin for all types
if field == 'origin':
continue
# Check if field is valid for this type
if field not in allowed and field in additional_fields - {'origin'}:
# Remove invalid field instead of raising error (for compatibility)
data.pop(field, None)
return data
def create(self, validated_data):
"""Override create to convert data types for storage"""
# Convert integers to strings for storage
if 'height' in validated_data and validated_data['height'] is not None:
validated_data['height'] = str(validated_data['height'])
if 'fee' in validated_data and validated_data['fee'] is not None:
validated_data['fee'] = str(validated_data['fee'])
if 'value' in validated_data and validated_data['value'] is not None:
validated_data['value'] = str(validated_data['value'])
# Convert JSON objects to strings
if 'rate' in validated_data and validated_data['rate'] is not None:
validated_data['rate'] = json.dumps(validated_data['rate'])
if 'fmv' in validated_data and validated_data['fmv'] is not None:
validated_data['fmv'] = json.dumps(validated_data['fmv'])
if 'heights' in validated_data and validated_data['heights'] is not None:
validated_data['heights'] = json.dumps(validated_data['heights'])
instance = super().create(validated_data)
return instance
def update(self, instance, validated_data):
"""Override update to convert data types for storage"""
# Convert integers to strings for storage
if 'height' in validated_data and validated_data['height'] is not None:
validated_data['height'] = str(validated_data['height'])
if 'fee' in validated_data and validated_data['fee'] is not None:
validated_data['fee'] = str(validated_data['fee'])
if 'value' in validated_data and validated_data['value'] is not None:
validated_data['value'] = str(validated_data['value'])
# Convert JSON objects to strings
if 'rate' in validated_data and validated_data['rate'] is not None:
validated_data['rate'] = json.dumps(validated_data['rate'])
if 'fmv' in validated_data and validated_data['fmv'] is not None:
validated_data['fmv'] = json.dumps(validated_data['fmv'])
if 'heights' in validated_data and validated_data['heights'] is not None:
validated_data['heights'] = json.dumps(validated_data['heights'])
return super().update(instance, validated_data)
def to_representation(self, instance):
"""Convert stored data back to API format"""
data = super().to_representation(instance)
# Convert string integers back to integers
if data.get('height'):
try:
data['height'] = int(data['height'])
except (ValueError, TypeError):
data['height'] = None
if data.get('fee'):
try:
data['fee'] = int(data['fee'])
except (ValueError, TypeError):
data['fee'] = None
if data.get('value'):
try:
data['value'] = int(data['value'])
except (ValueError, TypeError):
data['value'] = None
# Convert JSON strings back to objects
if data.get('rate'):
try:
data['rate'] = json.loads(data['rate'])
except (json.JSONDecodeError, TypeError):
data['rate'] = None
if data.get('fmv'):
try:
data['fmv'] = json.loads(data['fmv'])
except (json.JSONDecodeError, TypeError):
data['fmv'] = None
if data.get('heights'):
try:
data['heights'] = json.loads(data['heights'])
except (json.JSONDecodeError, TypeError):
data['heights'] = None
return data
class LabelbaseSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
super(LabelbaseSerializer, self).__init__(*args, **kwargs)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -37,7 +37,8 @@ def generate_config_file(config_file_path="config.ini"):
'name': 'labelbase',
'user': 'ulabelbase',
'password': database_password,
# 'host': '127.0.0.1'
'host': os.getenv("MYSQL_HOST", "127.0.0.1"),
'port': os.getenv("MYSQL_PORT", "3306"),
}
with open(config_file_path, 'w') as configfile:

View file

@ -185,8 +185,8 @@ DATABASES = {
"USER": proj_config.get("database", "user"),
"OPTIONS": {"charset": "utf8mb4"},
"PASSWORD": proj_config.get("database", "password"),
'HOST': 'localhost',
'PORT': 3306,
'HOST': proj_config.get("database", "host", fallback="labelbase_mysql"),
'PORT': int(proj_config.get("database", "port", fallback="3306")),
'OPTIONS': {
'charset': 'utf8mb4',
},

View file

@ -10,10 +10,13 @@ from userprofile.views import (ProfileView,
ProfileAvatarUpdateView,
ProfileCurrencyUpdateView,
MempoolUpdateView,
ProfileFeeUpdateView,
ElectrumInfoUpdateView)
from userprofile.views import APIKeyView
from userprofile.views import has_seen_welcome_popup
from hashtags.views import HashtagListView, HashtagUpdateView, LabelbaseProxyView
@ -46,8 +49,14 @@ from .views import (
#LabelbasePortfolioView,
OutputStatUpdateRedirectView,
BitcoinAddressDatatableView,
CurrencySyncView,
CurrencySyncActionView,
FillMissingDataView,
FillMissingDataActionView,
FillOutputFieldsActionView
)
from importer.views import upload_labels
from django.contrib.auth import views as auth_views
@ -93,6 +102,11 @@ urlpatterns = [
login_required(MempoolUpdateView.as_view()),
name="userprofile_mempool",
),
path(
"account/userprofile-mempool-fees/",
login_required(ProfileFeeUpdateView.as_view()),
name="userprofile_fees",
),
path(
"account/userprofile-currency/",
login_required(ProfileCurrencyUpdateView.as_view()),
@ -150,6 +164,30 @@ urlpatterns = [
login_required(LabelbaseMergeView.as_view()),
name="labelbase_merge"
),
path(
'labelbase/<int:labelbase_id>/currency-sync/',
CurrencySyncView.as_view(),
name='currency_sync'
),
path(
'labelbase/<int:labelbase_id>/currency-sync/action/',
CurrencySyncActionView.as_view(),
name='currency_sync_action'
),
path(
'labelbase/<int:labelbase_id>/fill-missing-data/',
FillMissingDataView.as_view(),
name='fill_missing_data'
),
path('label/<int:label_id>/fill-output-fields/',
FillOutputFieldsActionView.as_view(),
name='fill_output_fields_action'),
path(
'labelbase/<int:labelbase_id>/fill-missing-data/action/',
FillMissingDataActionView.as_view(),
name='fill_missing_data_action'
),
#path(
# "labelbase/<int:labelbase_id>/portfolio/",
# login_required(LabelbasePortfolioView.as_view()),

View file

@ -1,6 +1,11 @@
import logging
from datetime import datetime
from decimal import Decimal
import os
import time
import re
import tempfile
import json
from django.conf import settings
from django.contrib.auth.forms import UserCreationForm
from django.shortcuts import redirect, resolve_url
@ -27,20 +32,17 @@ from labelbase.forms import LabelForm, LabelbaseForm
from labelbase.forms import ExportLabelsForm
from finances.models import OutputStat
from finances.tasks import check_all_outputs
from finances.models import HistoricalPrice
from .utils import hashtag_to_badge, extract_fiat_value
from embit import bip32, script
from embit.networks import NETWORKS
from django.http import JsonResponse
from django_datatables_view.base_datatable_view import BaseDatatableView
from embit import bip32, script
from embit.networks import NETWORKS
logger = logging.getLogger('labelbase')
DEFAULT_DERIVE_ADDRESS_COUNT = 100
class BitcoinAddressDatatableView(BaseDatatableView):
@ -234,8 +236,10 @@ class LabelbaseDeleteView(DeleteView):
class LabelbaseDatatableView(BaseDatatableView):
model = Label
columns = ["id", "type", "ref", "label", "origin", "spendable"]
columns = ["id", "type", "ref", "label", "origin", "spendable",
"height", "time", "fee", "value", "rate",
"keypath", "fmv", "heights"
]
order_columns = ["id", "type", "ref", "label", "origin", "spendable"]
max_display_length = 100
@ -298,6 +302,7 @@ class LabelbaseDatatableView(BaseDatatableView):
def filter_queryset(self, qs):
search = self.request.GET.get('search[value]', None)
type_filter = self.request.GET.get('type', None)
if search:
# Due to encryption, we need to use a super slow process here...
res_ids = []
@ -315,7 +320,9 @@ class LabelbaseDatatableView(BaseDatatableView):
if record.origin and search in record.origin.lower():
res_ids.append(record.id)
continue
return qs.filter(id__in=res_ids)
qs = qs.filter(id__in=res_ids)
if type_filter and type_filter != 'all':
qs = qs.filter(type=type_filter)
return qs
@ -557,7 +564,7 @@ class TreeMapsView(ListView):
context['action'] = self.kwargs.get('action', 'unspent-outputs')
return context
def get_queryset(self):
def get_queryset_OLD(self):
#action = self.kwargs.get('action', 'unspent-outputs')
label_ids = []
@ -589,6 +596,38 @@ class TreeMapsView(ListView):
qs = Label.objects.none()
return qs.order_by("id")
def get_queryset(self):
label_ids = []
qs = Label.objects.filter(
labelbase__user_id=self.request.user.id,
labelbase_id=self.kwargs["pk"],
)
action = self.kwargs.get('action', 'unspent-outputs')
for l in qs:
if l.type == "output":
output = OutputStat.objects.filter(
user=l.labelbase.user,
type_ref_hash=l.type_ref_hash,
network=l.labelbase.network).last()
if output and output.spent is False:
# For fee-efficiency, only show spendable outputs
if action == 'fee-efficiency':
if l.spendable is True:
label_ids.append(l.id)
else:
label_ids.append(l.id)
if label_ids:
qs = qs.filter(id__in=label_ids,
labelbase__user_id=self.request.user.id,
labelbase_id=self.kwargs["pk"])
else:
qs = Label.objects.none()
return qs.order_by("id")
class LabelbasePortfolioView(LabelbaseView):
template_name = "labelbase_portfolio.html"
@ -722,6 +761,210 @@ class FixAndMergeLabelsView(View):
})
class CurrencySyncView(View):
template_name = "currency_sync.html"
def get(self, request, *args, **kwargs):
labelbase_id = self.kwargs["labelbase_id"]
labelbase = get_object_or_404(Labelbase, id=labelbase_id, user_id=request.user.id)
# Get all output and input labels (types that support fmv)
labels = Label.objects.filter(
labelbase_id=labelbase_id,
type__in=['output', 'input']
)
# Categorize
text_only = [] # Has currency in label, no FMV
fmv_only = [] # Has FMV, no currency in label
conflicts = [] # Both exist but don't match
synced = [] # Both exist and match
for label in labels:
label_currency = extract_fiat_value(label.label) # (value, currency)
fmv_data = self._parse_fmv(label.fmv) # Parse JSON
has_label_currency = label_currency[0] > 0
has_fmv = fmv_data is not None
if has_label_currency and not has_fmv:
text_only.append(label)
elif has_fmv and not has_label_currency:
fmv_only.append(label)
elif has_label_currency and has_fmv:
if self._currencies_match(label_currency, fmv_data):
synced.append(label)
else:
conflicts.append(label)
return render(request, self.template_name, {
'labelbase': labelbase,
'text_only': text_only,
'fmv_only': fmv_only,
'conflicts': conflicts,
'synced': synced,
'active_labelbase_id': labelbase_id,
})
def _parse_fmv(self, fmv_str):
"""Parse FMV JSON string"""
if not fmv_str or not fmv_str.strip():
return None
try:
return json.loads(fmv_str)
except (json.JSONDecodeError, ValueError):
return None
def _currencies_match(self, label_currency, fmv_data):
"""Check if label currency matches FMV"""
value, currency = label_currency
if currency not in fmv_data:
return False
# Get FMV value and convert to Decimal if it's a string
fmv_value = fmv_data[currency]
if isinstance(fmv_value, str):
try:
fmv_value = Decimal(fmv_value)
except (ValueError, TypeError):
return False
else:
fmv_value = Decimal(str(fmv_value))
# Convert label value to Decimal if needed
if not isinstance(value, Decimal):
value = Decimal(str(value))
# Compare with small tolerance for rounding differences
return abs(fmv_value - value) < Decimal('0.01')
#import json
#from django.views import View
#from django.shortcuts import get_object_or_404, redirect
#from django.http import HttpResponseRedirect
#from django.urls import reverse
#from django.contrib import messages
#from labelbase.models import Label
#from labellabor.utils import extract_fiat_value
class CurrencySyncActionView(View):
def post(self, request, *args, **kwargs):
labelbase_id = self.kwargs["labelbase_id"]
label_id = request.POST.get('label_id')
action = request.POST.get('action')
if action == 'sync_all_text_to_fmv':
# Batch sync: text → FMV
labels = Label.objects.filter(
labelbase_id=labelbase_id,
labelbase__user_id=request.user.id,
type__in=['output', 'input']
)
count = 0
for label in labels:
value, currency = extract_fiat_value(label.label)
if value > 0 and currency:
# Convert Decimal to string, then create dict, then JSON
fmv_dict = {currency: str(value)}
label.fmv = json.dumps(fmv_dict)
label.save()
count += 1
messages.success(request, f"Synced {count} labels from text to FMV field.")
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
elif action == 'sync_all_fmv_to_text':
labels = Label.objects.filter(
labelbase_id=labelbase_id,
labelbase__user_id=request.user.id,
type__in=['output', 'input']
)
count = 0
for label in labels:
if label.fmv and label.fmv.strip():
try:
fmv_data = json.loads(label.fmv)
if fmv_data:
currency, value_str = list(fmv_data.items())[0]
value = Decimal(value_str) if isinstance(value_str, str) else Decimal(str(value_str))
existing_value, existing_currency = extract_fiat_value(label.label)
if existing_value == 0 or not existing_currency:
if label.label:
label.label = f"{label.label} {currency} {value:.2f}".strip()
else:
label.label = f"{currency} {value:.2f}".strip()
label.save()
count += 1
except (json.JSONDecodeError, ValueError) as e:
logging.warning(f"Error processing label {label.id}: {e}")
continue
messages.success(request, f"Synced {count} labels from FMV to text field.")
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
# Single label actions
if not label_id:
messages.error(request, "No label specified.")
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
label = get_object_or_404(
Label,
id=label_id,
labelbase__user_id=request.user.id
)
if action == 'text_to_fmv':
# Extract from label text and populate FMV
value, currency = extract_fiat_value(label.label)
if value > 0 and currency:
# Store as string to preserve precision
fmv_dict = {currency: str(value)}
label.fmv = json.dumps(fmv_dict)
label.save()
messages.success(request, f"Synced currency from label text to FMV field.")
else:
messages.warning(request, "No valid currency found in label text.")
elif action == 'fmv_to_text':
# Extract from FMV and update label text
if label.fmv and label.fmv.strip():
try:
fmv_data = json.loads(label.fmv)
if fmv_data:
currency, value_str = list(fmv_data.items())[0]
# Parse value back from string
value = Decimal(value_str) if isinstance(value_str, str) else Decimal(str(value_str))
# Check if currency already in label
existing_value, existing_currency = extract_fiat_value(label.label)
if existing_value == 0 or not existing_currency: # No currency in label
# Append to label with proper decimal formatting
if label.label:
label.label = f"{label.label} {currency} {value:.2f}".strip()
else:
label.label = f"{currency} {value:.2f}".strip()
label.save()
messages.success(request, f"Synced currency from FMV to label text.")
else:
# Pattern to match currency and value like "CHF 615.00" or "USD 1000.50"
pattern = r'\b' + re.escape(existing_currency) + r'\s+\d+\.?\d*\b'
new_text = f"{currency} {value:.2f}"
label.label = re.sub(pattern, new_text, label.label)
label.save()
messages.success(request, f"Updated currency in label text from FMV.")
except (json.JSONDecodeError, ValueError) as e:
messages.error(request, f"Error parsing FMV data: {e}")
else:
messages.warning(request, "No FMV data found.")
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
class ExportLabelsView(View):
def post(self, request, *args, **kwargs):
labelbase_id = self.kwargs["labelbase_id"]
@ -781,11 +1024,54 @@ class ExportLabelsView(View):
"ref": label.ref,
"label": label.label,
}
if label.origin and label.type == "tx":
label_entry["origin"] = label.origin
if label.spendable in [True, False] and label.type == "output":
label_entry["spendable"] = label.spendable
# Additional BIP-329 fields with robust error handling
# Integer fields (height, fee, value)
if label.height:
try:
label_entry["height"] = int(label.height)
except (ValueError, TypeError) as e:
logging.warning(f"Invalid height value for label {label.id}: {e}")
if label.time:
label_entry["time"] = label.time
if label.fee:
try:
label_entry["fee"] = int(label.fee)
except (ValueError, TypeError) as e:
logging.warning(f"Invalid fee value for label {label.id}: {e}")
if label.value:
try:
label_entry["value"] = int(label.value)
except (ValueError, TypeError) as e:
logging.warning(f"Invalid value for label {label.id}: {e}")
# JSON fields (rate, fmv, heights)
if label.rate and label.rate.strip():
try:
label_entry["rate"] = json.loads(label.rate)
except json.JSONDecodeError as e:
logging.warning(f"Invalid rate JSON for label {label.id}: {e}")
if label.keypath:
label_entry["keypath"] = label.keypath
if label.fmv and label.fmv.strip():
try:
label_entry["fmv"] = json.loads(label.fmv)
except json.JSONDecodeError as e:
logging.warning(f"Invalid fmv JSON for label {label.id}: {e}")
if label.heights and label.heights.strip():
try:
label_entry["heights"] = json.loads(label.heights)
except json.JSONDecodeError as e:
logging.warning(f"Invalid heights JSON for label {label.id}: {e}")
# Write the label entry to the file
label_writer.write_label(label_entry)
@ -814,7 +1100,11 @@ class ExportLabelsView(View):
class LabelUpdateView(UpdateView):
model = Label
fields = ["type", "ref", "label", "origin", "spendable"]
fields = [
"type", "ref", "label", "origin", "spendable",
"height", "time", "fee", "value", "rate",
"keypath", "fmv", "heights"
]
def get_object(self):
user_id = self.request.user.id
@ -840,6 +1130,7 @@ class LabelUpdateView(UpdateView):
context["active_labelbase_id"] = self.object.labelbase.id
context["labelbase"] = self.object.labelbase
context["action"] = self.kwargs.get('action', 'update')
if context["action"] in ["labeling", "derive-addresses"]:
context["labelform"] = LabelForm(
request=self.request, labelbase_id=self.object.labelbase.id
@ -849,15 +1140,32 @@ class LabelUpdateView(UpdateView):
mempool_api = self.object.labelbase.get_mempool_api()
context["res_tx"] = mempool_api.get_transaction(self.object.ref)
if self.object.type == "xpub":
context["address_count"] = self.request.GET.get("address_count", DEFAULT_DERIVE_ADDRESS_COUNT)
context["offset"] = int(self.request.GET.get("offset", 0))
if self.object.type == "output":
context["output"] = OutputStat.objects.filter(
user=self.object.labelbase.user,
type_ref_hash=self.object.type_ref_hash).last()
output_stat = OutputStat.objects.filter(
user=self.object.labelbase.user,
type_ref_hash=self.object.type_ref_hash
).last()
context["output"] = output_stat
# Convert Unix timestamp to human-readable UTC formats
if output_stat and output_stat.confirmed_at_block_time:
dt = datetime.utcfromtimestamp(output_stat.confirmed_at_block_time)
context["output_block_time_utc"] = dt.strftime('%Y-%m-%d %H:%M:%S')
context["output_block_time_iso"] = dt.strftime('%Y-%m-%dT%H:%M:%SZ')
# Check for missing BIP-329 fields
if context["action"] == "output-details":
missing_fields = []
applicable_fields = ['height', 'time', 'value']
for field in applicable_fields:
value = getattr(self.object, field, None)
if not value or (isinstance(value, str) and not value.strip()):
missing_fields.append(field)
context["missing_fields"] = missing_fields
return context
@ -965,3 +1273,181 @@ class OutputStatUpdateRedirectView(View):
)
label.save() # will trigger a check agains Electrum
return redirect('edit_label', pk=label_id)
class FillMissingDataView(View):
template_name = "fill_missing_data.html"
def get(self, request, *args, **kwargs):
labelbase_id = self.kwargs["labelbase_id"]
labelbase = get_object_or_404(Labelbase, id=labelbase_id, user_id=request.user.id)
# Get all output labels (only type that uses OutputStat)
output_labels = Label.objects.filter(
labelbase_id=labelbase_id,
type='output'
)
can_fill_from_outputstat = []
already_complete = []
for label in output_labels:
missing_fields = self._get_missing_fields(label)
if not missing_fields:
already_complete.append(label)
continue
# Check if OutputStat exists
output_stat = OutputStat.objects.filter(
user=label.labelbase.user,
type_ref_hash=label.type_ref_hash,
network=label.labelbase.network
).first()
if output_stat:
can_fill_from_outputstat.append({
'label': label,
'missing_fields': missing_fields,
'output_stat': output_stat
})
return render(request, self.template_name, {
'labelbase': labelbase,
'can_fill_from_outputstat': can_fill_from_outputstat,
'already_complete': already_complete,
'active_labelbase_id': labelbase_id,
})
def _get_missing_fields(self, label):
"""Return list of fields that are missing for output type"""
missing = []
# Only check fields applicable to outputs
applicable_fields = ['height', 'time', 'value']
for field in applicable_fields:
value = getattr(label, field, None)
if not value or (isinstance(value, str) and not value.strip()):
missing.append(field)
return missing
class FillMissingDataActionView(View):
def post(self, request, *args, **kwargs):
labelbase_id = self.kwargs["labelbase_id"]
action = request.POST.get('action')
label_id = request.POST.get('label_id')
if action == 'fill_all_from_outputstat':
count = self._fill_all_from_outputstat(request.user.id, labelbase_id)
messages.success(request, f"Filled {count} labels from OutputStat data.")
elif action == 'fill_single_from_outputstat':
label = get_object_or_404(Label, id=label_id, labelbase__user_id=request.user.id)
if self._fill_label_from_outputstat(label):
messages.success(request, "Filled label data from OutputStat.")
else:
messages.error(request, "Could not fill label data.")
return HttpResponseRedirect(reverse('fill_missing_data', kwargs={'labelbase_id': labelbase_id}))
def _fill_all_from_outputstat(self, user_id, labelbase_id):
"""Fill all output labels that have OutputStat data"""
labels = Label.objects.filter(
labelbase_id=labelbase_id,
labelbase__user_id=user_id,
type='output'
)
count = 0
for label in labels:
if self._fill_label_from_outputstat(label):
count += 1
return count
def _fill_label_from_outputstat(self, label):
"""Fill a single label from OutputStat data"""
output_stat = OutputStat.objects.filter(
user=label.labelbase.user,
type_ref_hash=label.type_ref_hash,
network=label.labelbase.network
).first()
if not output_stat:
return False
updated = False
# Fill height (only if empty)
if not label.height and output_stat.confirmed_at_block_height:
label.height = str(output_stat.confirmed_at_block_height)
updated = True
# Fill time (only if empty)
if not label.time and output_stat.confirmed_at_block_time:
# Convert Unix timestamp to ISO-8601
dt = datetime.utcfromtimestamp(output_stat.confirmed_at_block_time)
label.time = dt.strftime('%Y-%m-%dT%H:%M:%SZ')
updated = True
# Fill value (only if empty)
if not label.value and output_stat.value:
label.value = str(output_stat.value)
updated = True
if updated:
label.save()
return updated
class FillOutputFieldsActionView(View):
"""Single output field fill action from output-details page"""
def post(self, request, *args, **kwargs):
label_id = self.kwargs["label_id"]
label = get_object_or_404(
Label,
id=label_id,
labelbase__user_id=request.user.id,
type='output'
)
# Reuse the existing fill logic
if self._fill_label_from_outputstat(label):
messages.success(request, "Successfully filled BIP-329 fields from OutputStat data.")
else:
messages.error(request, "Could not fill fields. OutputStat data may not be available.")
return HttpResponseRedirect(
reverse('edit_label', kwargs={'pk': label_id}) + '?action=output-details'
)
def _fill_label_from_outputstat(self, label):
"""Fill a single label from OutputStat data (reused from FillMissingDataActionView)"""
output_stat = OutputStat.objects.filter(
user=label.labelbase.user,
type_ref_hash=label.type_ref_hash,
network=label.labelbase.network
).first()
if not output_stat:
return False
updated = False
if not label.height and output_stat.confirmed_at_block_height:
label.height = str(output_stat.confirmed_at_block_height)
updated = True
if not label.time and output_stat.confirmed_at_block_time:
dt = datetime.utcfromtimestamp(output_stat.confirmed_at_block_time)
label.time = dt.strftime('%Y-%m-%dT%H:%M:%SZ')
updated = True
if not label.value and output_stat.value:
label.value = str(output_stat.value)
updated = True
if updated:
label.save()
return updated

View file

@ -1,13 +1,14 @@
gunicorn
asgiref==3.4.1
certifi==2024.07.04
cffi==1.15.1
charset-normalizer==2.0.12
asgiref
certifi
cffi
charset-normalizer
coreapi==2.3.3
coreschema==0.0.4
cryptography==42.0.4
pycryptodome
Django==3.2.25
#Django==4.2.26
django-appconf==1.0.5
django-bootstrap-form==3.4
django-classy-tags==2.0.0
@ -40,9 +41,9 @@ six==1.16.0
sqlparse==0.5.0
typing_extensions==4.1.1
uritemplate==4.1.1
urllib3==1.26.18
urllib3>=2.6.0
zipp==3.6.0
bip329==0.0.3
bip329==1.0.0
pymempool==0.0.5
django-datatables-view==1.20.0
django-money==3.3

View file

@ -184,12 +184,13 @@
{% endif %}
{% endwith %}
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_fix_and_manage" labelbase_id=labelbase.id %}"><span data-feather="git-merge" class="align-text-bottom"></span> Fix & Manage</a></li>
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "fill_missing_data" labelbase_id=labelbase.id %}"><span data-feather="refresh-cw" class="align-text-bottom"></span> Sync Fields</a></li>
{% if request.user.profile.use_fiatfinances %}
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "currency_sync" labelbase_id=labelbase.id %}"><span data-feather="dollar-sign" class="align-text-bottom"></span> Fiat Finances</a></li>
{% endif %}
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_health" labelbase_id=labelbase.id %}"><span data-feather="activity" class="align-text-bottom"></span>UTXOs Health</a></li>{% endcomment %}
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_tree_maps" pk=labelbase.id %}unspent-outputs/"><span data-feather="grid" class="align-text-bottom"></span> Tree Map</a></li>
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_stats_and_kpi" labelbase_id=labelbase.id %}"><span data-feather="pie-chart" class="align-text-bottom"></span> Stats & KPIs</a></li>{% endcomment %}
{% if request.user.profile.use_fiatfinances %}
<li><a class="nav-link" style="font-size: .875rem;"><span data-feather="dollar-sign" class="align-text-bottom"></span> Fiat Finances</a></li>
{% endif %}
<li><a class="nav-link" style="font-size: .875rem;" data-bs-toggle="modal" data-bs-target="#importLabelbaseModal"><span data-feather="upload-cloud" class="align-text-bottom"></span> Import</a></li>
<li><a class="nav-link" style="font-size: .875rem;" data-bs-toggle="modal" data-bs-target="#exportLabelbaseModal"><span data-feather="download-cloud" class="align-text-bottom"></span> Export</a></li>
{% if labelbase.user.profile.use_hashtags %}
@ -343,54 +344,21 @@
{% endblock %}
<!-- Chatwoot Modal -->
<div class="modal " id="chatModal" tabindex="-1" aria-labelledby="chatModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="chatModalLabel">Support Chat Disclosure</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>By clicking "Load Chatwoot Support Chat," you will be connected to a third-party
support chat service provided by Chatwoot. Your interactions may be subject to their
<a href="https://www.chatwoot.com/terms-of-service">terms of service</a> and <a href="https://www.chatwoot.com/privacy-policy">privacy policy</a>.</p>
<p>The chat will be embedded on the bottom right of the page and you can disable in your <a href="{% url 'userprofile' %}">Profile Settings</a>.</p>
<p>Do you want to proceed?</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-primary" onclick="loadChat()">Load Chatwoot Support Chat</button>
</div>
</div>
</div>
</div>
{% csrf_token %}
{% include "_modal_importWizzardModal.html" %}
{% include "_modal_exportLabelbaseModal.html" %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js" integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/feather-icons@4.28.0/dist/feather.min.js" integrity="sha384-uO3SXW5IuS1ZpFPKugNNWqTZRRglnUJK6UAZ/gxOX80nxEkN9NcGZTftn6RzhGWE" crossorigin="anonymous"></script>
<!--script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js" integrity="sha384-zNy6FEbO50N+Cg5wap8IKA4M/ZnLJgzc6w2NqACZaK0u0FXfOWRRJOnQtpZun8ha" crossorigin="anonymous"></script-->
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<script src="https://cdn.datatables.net/1.13.1/js/jquery.dataTables.min.js"></script>
<script src="{% static 'js/jquery-3.5.1.min.js' %}"></script>
<script src="{% static 'js/qrcode.min.js' %}"></script>
<script src="{% static 'js/bootstrap.bundle.min.js' %}"></script>
<script src="{% static 'js/feather.min.js' %}"></script>
<script src="{% static 'js/jquery.dataTables.min.js' %}"></script>
<script src="{% static 'js/chart.js' %}"></script>
<script src="{% static 'js/pdfobject.min.js' %}"></script>
<script src="{% static 'js/dataTables.bootstrap5.min.js' %}"></script>
<script src="{% static 'lightbox/js/lightbox.js' %}"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
var searchInput = document.getElementById("searchInput");

View file

@ -0,0 +1,212 @@
{% extends "_base.html" %}
{% load i18n %}
{% load labelbase_tags %}
{% load sekizai_tags %}
{% block content %}
{% if labelbase %}
<div class="row">
<div class="col float-start">
<h2 style="padding-top: 1em;">Currency Data Sync - {{ labelbase.name }}</h2>
{% if labelbase.fingerprint %}<tt>{{ labelbase.fingerprint }}</tt>{% endif %}
{% if labelbase.about %}
<p>{{ labelbase.about }}</p>
{% endif %}
</div>
</div>
<div class="col">
<p>
Sync currency data between label text (legacy format like "CHF 615.00") and the structured FMV field.
</p>
{% if text_only or fmv_only or conflicts %}
<div class="alert bd-callout bd-callout-info">
<strong>{{ text_only|length|add:fmv_only|length|add:conflicts|length }} labels need attention!</strong>
</div>
{% else %}
<div class="alert bd-callout bd-callout-good">
<strong>All good!</strong> All {{ synced|length }} labels have matching currency data.
</div>
{% endif %}
<!-- Card 1: Currency in label text but missing FMV -->
{% if text_only %}
<div class="card mt-3">
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#collapse_text_only">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span>{{ text_only|length }} Labels with currency in text but no FMV field</span>
<button class="btn btn-primary" type="button">Review</button>
</h5>
<p class="mb-0">These labels have currency values in the label text that can be synced to the FMV field.</p>
</div>
<div id="collapse_text_only" class="collapse">
<div class="card-body">
<ul class="list-unstyled">
{% for label in text_only %}
<li class="border-bottom pb-3 mb-3">
<div class="row">
<div class="col-md-8">
<strong>{{ label.get_type_display }}</strong>: <tt>{{ label.ref }}</tt><br>
<strong>Label:</strong> <tt>{{ label.label }}</tt><br>
<strong>FMV:</strong> <span class="text-muted">(empty)</span>
</div>
<div class="col-md-4 text-end">
<form method="post" action="{% url 'currency_sync_action' labelbase_id=labelbase.id %}" style="display: inline;">
{% csrf_token %}
<input type="hidden" name="label_id" value="{{ label.id }}">
<input type="hidden" name="action" value="text_to_fmv">
<button type="submit" class="btn btn-sm btn-outline-primary">Sync to FMV →</button>
</form>
<a href="{% url 'edit_label' label.id %}" class="btn btn-sm btn-outline-secondary">Edit</a>
</div>
</div>
</li>
{% endfor %}
</ul>
<form method="post" action="{% url 'currency_sync_action' labelbase_id=labelbase.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="sync_all_text_to_fmv">
<button type="submit" class="btn btn-primary">Sync All to FMV →</button>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Card 2: FMV but no currency in label text -->
{% if fmv_only %}
<div class="card mt-3">
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#collapse_fmv_only">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span>{{ fmv_only|length }} Labels with FMV but no currency in text</span>
<button class="btn btn-primary" type="button">Review</button>
</h5>
<p class="mb-0">These labels have FMV data that can be added to the label text.</p>
</div>
<div id="collapse_fmv_only" class="collapse">
<div class="card-body">
<ul class="list-unstyled">
{% for label in fmv_only %}
<li class="border-bottom pb-3 mb-3">
<div class="row">
<div class="col-md-8">
<strong>{{ label.get_type_display }}</strong>: <tt>{{ label.ref }}</tt><br>
<strong>Label:</strong> <tt>{{ label.label|default:"(empty)" }}</tt><br>
<strong>FMV:</strong> <tt>{{ label.fmv }}</tt>
</div>
<div class="col-md-4 text-end">
<form method="post" action="{% url 'currency_sync_action' labelbase_id=labelbase.id %}" style="display: inline;">
{% csrf_token %}
<input type="hidden" name="label_id" value="{{ label.id }}">
<input type="hidden" name="action" value="fmv_to_text">
<button type="submit" class="btn btn-sm btn-outline-primary">← Sync to Label</button>
</form>
<a href="{% url 'edit_label' label.id %}" class="btn btn-sm btn-outline-secondary">Edit</a>
</div>
</div>
</li>
{% endfor %}
</ul>
<form method="post" action="{% url 'currency_sync_action' labelbase_id=labelbase.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="sync_all_fmv_to_text">
<button type="submit" class="btn btn-primary">← Sync All to Label</button>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Card 3: Conflicting currency data -->
{% if conflicts %}
<div class="card mt-3">
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#collapse_conflicts">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span>{{ conflicts|length }} Labels with conflicting currency data</span>
<button class="btn btn-warning" type="button">Review</button>
</h5>
<p class="mb-0">These labels have mismatched currency values between label text and FMV field.</p>
</div>
<div id="collapse_conflicts" class="collapse">
<div class="card-body">
<ul class="list-unstyled">
{% for label in conflicts %}
<li class="border-bottom pb-3 mb-3">
<div class="row">
<div class="col-md-7">
<strong>{{ label.get_type_display }}</strong>: <tt>{{ label.ref }}</tt><br>
<strong>Label:</strong> <tt>{{ label.label }}</tt><br>
<strong>FMV:</strong> <tt>{{ label.fmv }}</tt>
</div>
<div class="col-md-5 text-end">
<form method="post" action="{% url 'currency_sync_action' labelbase_id=labelbase.id %}" style="display: inline;">
{% csrf_token %}
<input type="hidden" name="label_id" value="{{ label.id }}">
<input type="hidden" name="action" value="text_to_fmv">
<button type="submit" class="btn btn-sm btn-outline-primary">Keep Label →</button>
</form>
<form method="post" action="{% url 'currency_sync_action' labelbase_id=labelbase.id %}" style="display: inline;">
{% csrf_token %}
<input type="hidden" name="label_id" value="{{ label.id }}">
<input type="hidden" name="action" value="fmv_to_text">
<button type="submit" class="btn btn-sm btn-outline-primary">← Keep FMV</button>
</form>
<a href="{% url 'edit_label' label.id %}" class="btn btn-sm btn-outline-secondary">Edit Manually</a>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
<!-- Card 4: All synced -->
{% if synced %}
<div class="card mt-3">
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#collapse_synced">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span>✓ {{ synced|length }} Labels with matching currency data</span>
<button class="btn btn-success" type="button">View</button>
</h5>
<p class="mb-0">These labels have currency data properly synced between label text and FMV field.</p>
</div>
<div id="collapse_synced" class="collapse">
<div class="card-body">
<ul class="list-unstyled">
{% for label in synced %}
<li class="border-bottom pb-3 mb-3">
<div class="row">
<div class="col-md-9">
<strong>{{ label.get_type_display }}</strong>: <tt>{{ label.ref }}</tt><br>
<strong>Label:</strong> <tt>{{ label.label }}</tt><br>
<strong>FMV:</strong> <tt>{{ label.fmv }}</tt>
</div>
<div class="col-md-3 text-end">
<a href="{% url 'edit_label' label.id %}" class="btn btn-sm btn-outline-secondary">Edit</a>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
<p>Labelbase not found.</p>
{% endif %}
<script>
{% addtoblock "js" %}
// No additional JS needed for now
{% endaddtoblock %}
</script>
{% endblock %}

View file

@ -0,0 +1,128 @@
{% extends "_base.html" %}
{% load i18n %}
{% load labelbase_tags %}
{% load sekizai_tags %}
{% block content %}
{% if labelbase %}
<div class="row">
<div class="col float-start">
<h2 style="padding-top: 1em;">Fill Missing Data - {{ labelbase.name }}</h2>
{% if labelbase.fingerprint %}<tt>{{ labelbase.fingerprint }}</tt>{% endif %}
{% if labelbase.about %}
<p>{{ labelbase.about }}</p>
{% endif %}
</div>
</div>
<div class="col">
<p>
Auto-populate BIP-329 additional fields (height, time, value) for outputs from OutputStat data.
</p>
{% if can_fill_from_outputstat %}
<div class="alert bd-callout bd-callout-info">
<strong>{{ can_fill_from_outputstat|length }} output labels can be filled!</strong>
</div>
{% else %}
<div class="alert bd-callout bd-callout-good">
<strong>All good!</strong> All {{ already_complete|length }} output labels have their fields populated.
</div>
{% endif %}
<!-- Card 1: Can populate from OutputStat -->
{% if can_fill_from_outputstat %}
<div class="card mt-3">
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#collapse_outputstat">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span>{{ can_fill_from_outputstat|length }} Output labels can be filled from OutputStat</span>
<button class="btn btn-primary" type="button">Review</button>
</h5>
<p class="mb-0">These output labels have OutputStat data available. Can populate: height, time, value.</p>
</div>
<div id="collapse_outputstat" class="collapse">
<div class="card-body">
<ul class="list-unstyled">
{% for item in can_fill_from_outputstat %}
<li class="border-bottom pb-3 mb-3">
<div class="row">
<div class="col-md-8">
<strong>{{ item.label.get_type_display }}</strong>: <tt>{{ item.label.ref }}</tt><br>
<strong>Label:</strong> <tt>{{ item.label.label|default:"(empty)" }}</tt><br>
<strong>Missing fields:</strong>
<span class="badge bg-warning text-dark">{{ item.missing_fields|join:", " }}</span><br>
<strong>Available data:</strong>
<small class="text-muted">
height={{ item.output_stat.confirmed_at_block_height }},
time={{ item.output_stat.confirmed_at_block_time }},
value={{ item.output_stat.value }} sats
</small>
</div>
<div class="col-md-4 text-end">
<form method="post" action="{% url 'fill_missing_data_action' labelbase_id=labelbase.id %}" style="display: inline;">
{% csrf_token %}
<input type="hidden" name="label_id" value="{{ item.label.id }}">
<input type="hidden" name="action" value="fill_single_from_outputstat">
<button type="submit" class="btn btn-sm btn-outline-primary">Fill Now</button>
</form>
<a href="{% url 'edit_label' item.label.id %}" class="btn btn-sm btn-outline-secondary">Edit</a>
</div>
</div>
</li>
{% endfor %}
</ul>
<form method="post" action="{% url 'fill_missing_data_action' labelbase_id=labelbase.id %}">
{% csrf_token %}
<input type="hidden" name="action" value="fill_all_from_outputstat">
<button type="submit" class="btn btn-primary">Fill All from OutputStat</button>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Card 2: Already complete -->
{% if already_complete %}
<div class="card mt-3">
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#collapse_complete">
<h5 class="mb-0 d-flex justify-content-between align-items-center">
<span>✓ {{ already_complete|length }} Output labels already have all fields populated</span>
<button class="btn btn-success" type="button">View</button>
</h5>
<p class="mb-0">These output labels have all applicable BIP-329 additional fields (height, time, value) populated.</p>
</div>
<div id="collapse_complete" class="collapse">
<div class="card-body">
<ul class="list-unstyled">
{% for label in already_complete %}
<li class="border-bottom pb-2 mb-2">
<div class="row">
<div class="col-md-10">
<strong>{{ label.get_type_display }}</strong>: <tt>{{ label.ref }}</tt><br>
<small class="text-muted">{{ label.label }}</small>
</div>
<div class="col-md-2 text-end">
<a href="{% url 'edit_label' label.id %}" class="btn btn-sm btn-outline-secondary">Edit</a>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endif %}
</div>
{% else %}
<p>Labelbase not found.</p>
{% endif %}
<script>
{% addtoblock "js" %}
{% endaddtoblock %}
</script>
{% endblock %}

View file

@ -272,24 +272,7 @@
<p>Enhance your labels with attachments. Attach images and files directly to your labels, streamlining your documentation workflow and enriching the data associated with each label.</p>
</div>
</div>
{% comment %}
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Customized Email Sending</h3>
<p>Personalize email sending with custom server settings. Labelbase enables users to configure preferred SMTP and IMAP options, enhancing control, privacy and security.</p>
</div>
</div>
{% endcomment %}
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">On-demand Support Chat</h3>
<p>We are happy to assist you with the integrated on-demand support chat by Chatwoot.
<br>Privacy is ensured as the chat script loads only when you request it, which is totally optional.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Automated Output Management</h3>
@ -304,6 +287,47 @@
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">BIP-329 Extended Fields</h3>
<p>Full support for BIP-329 additional fields including transaction height, timestamp, fees, values, exchange rates, and derivation paths. Enrich your labels with comprehensive transaction metadata.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Currency Sync Tool</h3>
<p>Seamlessly synchronize between legacy currency annotations in label text and structured FMV fields. Automatically detect and resolve conflicts to maintain data consistency across formats.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Bulk Auto-Fill Missing Data</h3>
<p>Automatically bulk populate transaction metadata from OutputStat records. Fill block heights, timestamps, and values for your outputs with one click, saving time and ensuring accuracy.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">One-Click BIP-329 Field Population</h3>
<p>Automatically populate transaction metadata like block height, timestamp, and value directly from OutputStat records. View missing fields at a glance and fill them with a single click from the output details page.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Fee Health Monitoring</h3>
<p>Instantly visualize the cost-effectiveness of spending your UTXOs. Labelbase calculates and displays fee health status for each spendable output, color-coded to show if transaction fees would consume a healthy percentage of the output value.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">UTXO Fee Efficiency Visualization</h3>
<p>Visualize the cost-effectiveness of spending your UTXOs with color-coded treemaps. See at a glance which outputs have healthy fee-to-value ratios and which would be expensive to spend, helping you make informed decisions about consolidation and transaction planning.</p>
</div>
</div>
<div class="col d-flex align-items-start">
<div>

View file

@ -5,43 +5,12 @@
{% load backgroundtask_tags %}
{% load attachments_tags %}
{% block title %}{{ object.type }} {{ object.ref }}{% endblock %}
{% comment %}<!--
<li class="nav-item">
<a class="nav-link {% if action == "detail" %}active" aria-current="page" {% else %}"{% endif %} href="{% url 'edit_label' object.id %}labeling/">Transaction Detail</a>
</li>
<li class="nav-item">
<a class="nav-link {% if action == "history" %}active" aria-current="page"{% else %}"{% endif %} href="{#% url 'edit_label' object.id %#}">Label History</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Transaction Output Labeling</a>
</li>
<li class="nav-item">
<a class="nav-link {% if action == "labeling" %}active" aria-current="page" {% else %}"{% endif %} href="{% url 'edit_label' object.id %}labeling/">Derived Addresses Labeling</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Coin Value</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Coin History</a>
</li>
<div class="col-{% if action == "derive-addresses-void" %}10{% else %}12{% endif %} float-start">
-->{% endcomment %}
{% block title %}{{ object.type }} {{ object.ref }}{% endblock %}
{% block content %}
{% get_attachments_for object.get_label_attachment as my_attachments %}
<div class="row">
<ul class="nav nav-tabs" style="padding-top: 2rem;">
<li class="nav-item">
@ -120,51 +89,6 @@
</div>
{% endif %}
{% comment %}
{% is_label_id_in_queue object.id as is_in_queue %}
{% if is_in_queue %}
<div class="alert bd-callout bd-callout-info">
<!--div class="btn-group" role="group" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;">
<button type="button" class="btn btn-sm btn-outline-danger dropdown-toggle " data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=none">Verify output status</a></li>
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=true">Mark output as spent</a></li>
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=false">Mark output as unspent</a></li>
</ul>
</div-->
<strong>Output in queue!</strong> This output is currently in the processing queue. It will be checked shortly.
</div>
{% else %}
{% if object.type == "output" %}
{% switch output.get_spent_status %}
{% case "spent" %}
<div class="bd-callout bd-callout-warning">
<strong>Output spent!</strong> Blockchain records indicate that this output has been spent in another transaction.
</div>
{% case "unspent" %}
<div class="bd-callout bd-callout-good">
<strong>Output unspent!</strong> Blockchain records indicate that this output has not been spent yet.
</div>
{% case "unconfirmed" %}
<div class="alert bd-callout bd-callout-info">
<div class="btn-group" role="group" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;">
<button type="button" class="btn btn-sm btn-outline-info dropdown-toggle " data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=none">
Verify output status</a></li>
</ul>
</div>
<strong>Output unconfirmed!</strong> Blockchain records indicate that this output has not been confirmed yet.
</div>
{% endswitch %}
{% endif %}
{% endif %}
{% endcomment %}
{% is_label_id_in_queue object.id as is_in_queue %}
{% if is_in_queue %}
<div class="alert bd-callout bd-callout-info">
@ -204,32 +128,11 @@
{% endif %}
{% endif %}
<!--div class="bd-callout bd-callout-warning">
<strong>Heads up!</strong> There are multiple records for this transaction output. <a href="">Review & merge</a>
</div-->
{% comment %}
{% if object.type == "addr" %}
<div class="bd-callout bd-callout-warning">
<strong>Heads up!</strong> There are multiple transaction outputs sent to this address. For privacy reasons, do not reuse addresses.
</div>
{% endif %}
{% endcomment %}
{% block label_edit_content %}
{% endblock %}
</div>
<div class="modal" tabindex="-1" id="deleteLabelModal">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
@ -252,13 +155,160 @@
</div>
</form>
</div>
</div
</div>
</div>
<script>
{% addtoblock "js" %}
<script>
{% addtoblock "js" %}
{% endaddtoblock %}
// BIP-329 field visibility based on type
const fieldsByType = {
'tx': ['origin', 'height', 'time', 'fee', 'value', 'rate'],
'addr': ['origin', 'keypath', 'heights'],
'pubkey': ['origin', 'keypath'],
'input': ['origin', 'keypath', 'value', 'fmv', 'height', 'time'],
'output': ['origin', 'spendable', 'keypath', 'value', 'fmv', 'height', 'time'],
'xpub': ['origin']
};
function updateFieldVisibility() {
const typeField = document.getElementById('id_type');
if (!typeField) return;
const selectedType = typeField.value;
const allowedFields = fieldsByType[selectedType] || [];
// All additional fields (excluding core fields: type, ref, label)
const allAdditionalFields = ['origin', 'spendable', 'height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights'];
allAdditionalFields.forEach(fieldName => {
const field = document.getElementById(`id_${fieldName}`);
if (field) {
// Find the parent form-group/control-group div
const wrapper = field.closest('.form-group') || field.closest('.control-group') || field.closest('.mb-3') || field.parentElement.parentElement;
if (wrapper) {
if (allowedFields.includes(fieldName)) {
wrapper.style.display = '';
field.removeAttribute('disabled');
} else {
wrapper.style.display = 'none';
field.setAttribute('disabled', 'disabled');
field.value = ''; // Clear hidden fields
}
}
}
});
}
// Run on page load
document.addEventListener('DOMContentLoaded', function() {
updateFieldVisibility();
// Run when type changes
const typeField = document.getElementById('id_type');
if (typeField) {
typeField.addEventListener('change', updateFieldVisibility);
}
});
// Validate fmv field (JSON object with currency codes)
const fmvField = document.getElementById('id_fmv');
if (fmvField) {
fmvField.addEventListener('blur', function() {
try {
if (this.value && this.value.trim()) {
const parsed = JSON.parse(this.value);
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
alert('FMV must be a JSON object like {"USD": 1233.45}');
return;
}
// Validate values are numeric (accept both numbers and numeric strings)
for (const [key, value] of Object.entries(parsed)) {
// Check if it's a number OR a numeric string
const isNumeric = typeof value === 'number' ||
(typeof value === 'string' && !isNaN(parseFloat(value)) && isFinite(value));
if (!isNumeric) {
alert(`FMV value for ${key} must be numeric (got: ${typeof value})`);
return;
}
}
}
} catch (e) {
alert('Invalid JSON format for fmv field');
}
});
}
// Validate rate field (JSON object with currency codes)
const rateField = document.getElementById('id_rate');
if (rateField) {
rateField.addEventListener('blur', function() {
try {
if (this.value && this.value.trim()) {
const parsed = JSON.parse(this.value);
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
alert('Rate must be a JSON object like {"USD": 105620.00}');
return;
}
// Validate values are numeric (accept both numbers and numeric strings)
for (const [key, value] of Object.entries(parsed)) {
// Check if it's a number OR a numeric string
const isNumeric = typeof value === 'number' ||
(typeof value === 'string' && !isNaN(parseFloat(value)) && isFinite(value));
if (!isNumeric) {
alert(`Rate value for ${key} must be numeric (got: ${typeof value})`);
return;
}
}
}
} catch (e) {
alert('Invalid JSON format for rate field');
}
});
}
// Validate heights field (JSON array of integers)
const heightsField = document.getElementById('id_heights');
if (heightsField) {
heightsField.addEventListener('blur', function() {
try {
if (this.value && this.value.trim()) {
const parsed = JSON.parse(this.value);
if (!Array.isArray(parsed)) {
alert('Heights must be a JSON array like [123456, 789012]');
return;
}
// Validate all values are integers
for (const height of parsed) {
if (!Number.isInteger(height)) {
alert('All heights must be integers');
return;
}
}
}
} catch (e) {
alert('Invalid JSON format for heights field');
}
});
}
// Validate integer fields (height, fee, value)
const integerFields = ['id_height', 'id_fee', 'id_value'];
integerFields.forEach(fieldId => {
const field = document.getElementById(fieldId);
if (field) {
field.addEventListener('blur', function() {
if (this.value && this.value.trim()) {
if (!/^-?\d+$/.test(this.value.trim())) {
alert(`${fieldId.replace('id_', '')} must be an integer`);
}
}
});
}
});
{% endaddtoblock %}
</script>
{% endblock %}

View file

@ -168,6 +168,8 @@
<script>
{% addtoblock "js" %}
$(document).ready(function() {
$('.open-modal').click(function() {
var label = $(this).data('label');

View file

@ -1,35 +1,108 @@
{% extends "label_edit.html" %}
{% load bootstrap %}
{% load i18n %}
{% block label_edit_content %}
<!--
{{  res_tx|safe }}
-->
{% if action == "output-details" %}
{% if form.instance.type == "output" %}
<br>
<!-- ID: {{ output.id }} -->
<pre>
Output, ref: {{ label.ref }}
Output value, in sats: {{ output.value }}
Confirmed at block height: {{ output.confirmed_at_block_height }}
Confirmed at block time: {{ output.confirmed_at_block_time }}
Network: {{ output.get_network_display }}
Spent: {{ output.get_spent_status }}
{% if output.next_input_attributes %}
Fee estimation will be made based on:
<!-- BIP-329 Fields Status Alert (right after spent status in parent template) -->
{% if missing_fields %}
<div class="bd-callout bd-callout-info">
<strong>Missing BIP-329 fields:</strong>
{% for field in missing_fields %}
<span class="badge bg-warning text-dark">{{ field }}</span>
{% endfor %}
<br><small>Click the button below to populate from OutputStat data.</small>
</div>
{% else %}
<div class="bd-callout bd-callout-good">
<strong>✓ All fields populated!</strong> This label has all applicable BIP-329 fields filled.
</div>
{% endif %}
{{ output.next_input_attributes }}
<!-- Consolidated Output Details Card -->
<div class="card mb-3">
<div class="card-header">
<h5>Output Details & BIP-329 Fields</h5>
</div>
<div class="card-body">
<table class="table table-borderless">
<tr>
<td style="width: 30%;"><strong>Output ref:</strong></td>
<td><tt>{{ label.ref }}</tt></td>
</tr>
<tr>
<td><strong>Network:</strong></td>
<td>{{ output.get_network_display }}</td>
</tr>
<tr>
<td><strong>Spent status:</strong></td>
<td>{{ output.get_spent_status }}</td>
</tr>
<tr class="table-light">
<td colspan="2"><strong>BIP-329 Fields:</strong></td>
</tr>
<tr>
<td><strong>value:</strong></td>
<td>
{% if label.value %}
<tt>{{ label.value }}</tt> sats
{% else %}
<span class="text-muted">{{ output.value }} sats (available from OutputStat)</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>height:</strong></td>
<td>
{% if label.height %}
<tt>{{ label.height }}</tt>
{% else %}
<span class="text-muted">{{ output.confirmed_at_block_height }} (available from OutputStat)</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>time:</strong></td>
<td>
{% if label.time %}
<tt>{{ label.time }}</tt>
{% else %}
<span class="text-muted">{{ output_block_time_utc }} UTC (available from OutputStat)</span>
{% endif %}
</td>
</tr>
{% if output.next_input_attributes %}
<tr class="table-light">
<td colspan="2"><strong>Fee Estimation:</strong></td>
</tr>
<tr>
<td colspan="2">
{{ output.next_input_attributes }}
{% if output.next_input_attributes.input_n %}
<br><small>Assuming {{ output.next_input_attributes.input_m }}-of-{{ output.next_input_attributes.input_n }} multisig</small>
{% endif %}
</td>
</tr>
{% endif %}
</table>
{% if output.next_input_attributes.input_n %}
* Assuming {{ output.next_input_attributes.input_m }}-of-{{ output.next_input_attributes.input_n }} multisig
{% else %}
<!-- Fill Button (only show if fields are missing) -->
{% if missing_fields %}
<form method="post" action="{% url 'fill_output_fields_action' label_id=label.id %}">
{% csrf_token %}
<button type="submit" class="btn btn-primary">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-down-square" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M15 2a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V2zM0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2zm8.5 2.5a.5.5 0 0 0-1 0v5.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293V4.5z"/>
</svg>
Fill Missing BIP-329 Fields from OutputStat
</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
</pre>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -1,6 +1,8 @@
{% extends "label_edit.html" %}
{% load bootstrap %}
{% load i18n %}
{% load sekizai_tags %}
{% load attachments_tags %}
{% block label_edit_content %}
@ -33,7 +35,4 @@
</form>
</div><!-- lb-header container -->
{% endblock %}

View file

@ -23,6 +23,31 @@
<div class="table-responsive d-none d-md-block" style="padding-top:1em">
{% if label_list %}
<ul class="nav nav-tabs" id="typeFilterTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link {% if not request.GET.type or request.GET.type == 'all' %}active{% endif %}" data-type="all">All</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'tx' %}active{% endif %}" data-type="tx">Transactions</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'output' %}active{% endif %}" data-type="output">Outputs</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'input' %}active{% endif %}" data-type="input">Inputs</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'addr' %}active{% endif %}" data-type="addr">Addresses</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'pubkey' %}active{% endif %}" data-type="pubkey">Pubkeys</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link {% if request.GET.type == 'xpub' %}active{% endif %}" data-type="xpub">XPubs</button>
</li>
</ul>
<br>
<table id="bip329labels" class="table table-striped table-sm">
<thead>
<tr>
@ -32,6 +57,8 @@
<th scope="col">label</th>
<th scope="col">origin</th>
<th scope="col">spendable</th>
<th scope="col">health</th>
</tr>
</thead>
<tbody>
@ -123,6 +150,7 @@
<script>
{% addtoblock "js" %}
$(document).ready(function () {
window.currentTypeFilter = 'all';
const dt_table = $('#bip329labels').DataTable({
order: [[0, "asc"]],
columns: [
@ -131,15 +159,19 @@
{ data: 'ref', orderable: true, searchable: true },
{ data: 'label', orderable: true, searchable: true },
{ data: 'origin', orderable: true, searchable: true },
{ data: 'spendable', orderable: true, searchable: true }
{ data: 'spendable', orderable: true, searchable: true },
{ data: 'get_fee_health_status_display', orderable: false, searchable: false}
],
searching: true,
processing: false,
serverSide: true,
stateSave: true,
responsive: true,
ajax: {
url: "{% url 'labelbase_label_data' labelbase.pk %}?tag={{ request.GET.tag }}",
// url: "{% url 'labelbase_label_data' labelbase.pk %}?tag={{ request.GET.tag }}",
url: "{% url 'labelbase_label_data' labelbase.pk %}?tag={{ request.GET.tag }}&type={{ request.GET.type|default:'all' }}",
type: 'GET',
dataSrc: 'data'
},
@ -151,6 +183,21 @@
}
});
// Tab click handler - full page reload
$('#typeFilterTabs button').on('click', function() {
const typeFilter = $(this).data('type');
const urlParams = new URLSearchParams(window.location.search);
if (typeFilter === 'all') {
urlParams.delete('type');
} else {
urlParams.set('type', typeFilter);
}
// Full page reload with new URL
window.location.search = urlParams.toString();
});
function createCard(data) {
let originText = '';
if (data.origin) {
@ -160,6 +207,12 @@
if (data.type === '<tt>output</tt>' && (data.spendable === "<tt>true</tt>" || data.spendable === "<tt>false</tt>")) {
spendableText = `<tr><td> <strong>Spendable:</strong></td><td> ${data.spendable === "<tt>true</tt>" ? 'true' : 'false'}</td></tr>`;
}
let healthText = '';
if (data.get_fee_health_status_display) {
healthText = `<tr><td><strong>Health:</strong></td><td>${data.get_fee_health_status_display}</td></tr>`;
}
return `
<div class="col-12 mb-3">
<div class="card">
@ -184,6 +237,7 @@
</tr>
${originText ? `<tr><td colspan="2">${originText}</td></tr>` : ''}
${spendableText ? `${spendableText}` : ''}
${healthText}
</tbody>
</table>
</div>
@ -193,6 +247,7 @@
}
function renderMobileView(data, start, length, totalRecords, totalRecordsAll) {
$('#mobileLabels').empty();
data.forEach(function(item) {

View file

@ -16,6 +16,12 @@
<li class="nav-item">
<a class="nav-link {% if action == "unspent-spendable-outputs" %}active" aria-current="page" {% else %}"{% endif %} href="{% url "labelbase_tree_maps" pk=labelbase.id %}unspent-spendable-outputs/">Unspent Spendable Outputs</a>
</li>
<li class="nav-item" role="presentation">
<a class="nav-link {% if action == 'fee-efficiency' %}active{% endif %}"
href="{% url 'labelbase_tree_maps' labelbase.id 'fee-efficiency' %}">
Fee Efficiency (VTER)
</a>
</li>
</ul>

View file

@ -11,14 +11,26 @@
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.3"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-chart-treemap@0.2.3"></script>
{% if labelbase.is_testnet %}
{% if action == "fee-efficiency" %}
<div class="alert alert-dismissible bd-callout bd-callout-info">
<button type="button" class="btn-close" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;" data-bs-dismiss="alert" aria-label="Close"></button>
<div style="padding-right: 1.8rem;">
<strong>Fee Efficiency (VTER):</strong> Color shows how efficient it is to spend each output based on the fee-to-value ratio.
<br><br>
🟢 <strong>Healthy:</strong> Fee &lt; {{ request.user.profile.my_fee_threshold_healthy }}% of value<br>
🟡 <strong>Warning:</strong> Fee {{ request.user.profile.my_fee_threshold_healthy }}-{{ request.user.profile.my_fee_threshold_warning }}% of value<br>
🔴 <strong>High:</strong> Fee &gt; {{ request.user.profile.my_fee_threshold_warning }}% of value
<br><br>
<small>Box size represents output value in sats. Thresholds based on your fee settings. <a href="{% url 'userprofile_fees' %}">Adjust in Profile → Fees</a></small>
</div>
</div>
{% elif labelbase.is_testnet %}
<div class="alert alert-dismissible bd-callout bd-callout-info">
<button type="button" class="btn-close" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;" data-bs-dismiss="alert" aria-label="Close"></button>
<div style="padding-right: 1.8rem;">
<strong>Important Notice for Testnet Transactions:</strong> Testnet coins hold no real-world value and are solely intended for testing purposes.
</div>
</div
</div>
{% else %}
<div class="alert alert-dismissible bd-callout bd-callout-info ">
<button type="button" class="btn-close" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;" data-bs-dismiss="alert" aria-label="Close"></button>
@ -28,6 +40,7 @@ If the UTXO value is not provided, Labelbase will estimate it based on historica
</div>
</div>
{% endif %}
<div class="row ">
<style>
@ -40,8 +53,6 @@ If the UTXO value is not provided, Labelbase will estimate it based on historica
<canvas id="chart-area" style="height: 56vh;"></canvas>
</div>
<script type="text/javascript">
function colorFromValue(value, index, border) {
@ -53,7 +64,6 @@ function colorFromValue(value, index, border) {
} else {
var color = "#F7931A";
}
//var color = "orange";
if (border) {
alpha += 0.01;
}
@ -62,15 +72,11 @@ function colorFromValue(value, index, border) {
.rgbString();
}
function red_or_greem_fromValue(value, index, border) {
var alpha = (1 + Math.log(value)) / 5;
var color;
value = window.records[index].performance;
/* if (window.records[index].spendable == "false") {
color = "#dee2e6";
} else */
if (value > 0) {
color = "rgba(46, 204, 113, 1)"; // muted green color for positive values
} else if (value < 0) {
@ -88,12 +94,39 @@ function red_or_greem_fromValue(value, index, border) {
.rgbString();
}
window.records = [
function healthColorFromValue(value, index, border) {
var health_status = window.records[index].health_status;
var alpha = (1 + Math.log(value)) / 5;
var color;
switch(health_status) {
case 'green':
color = "rgba(46, 204, 113, 1)"; // Green
break;
case 'yellow':
color = "rgba(241, 196, 15, 1)"; // Yellow
break;
case 'red':
color = "rgba(231, 76, 60, 1)"; // Red
break;
default:
color = "#dee2e6"; // Gray - no health data
}
if (border) {
alpha += 0.01;
}
return Chart.helpers.color(color)
.alpha(alpha)
.rgbString();
}
window.records = [
{% for label in label_list %}
{% if action == "unspent-spendable-outputs" and label.spendable or action == "unspent-outputs" %}
{% if action == "unspent-spendable-outputs" and label.spendable or action == "unspent-outputs" or action == "fee-efficiency" %}
{
'spendable': {% if  label.spendable is None %}'Undefined'{% else %}'{{ label.spendable }}'{% endif %},
'spendable': {% if label.spendable is None %}'Undefined'{% else %}'{{ label.spendable }}'{% endif %},
'spent': '{{ label.get_finance_output_metrics_dict.spent }}',
'ref': '{{ label.ref }}',
'label': '{{ label.label }}',
@ -105,23 +138,24 @@ window.records = [
'type_ref_hash': '{{ label.get_finance_output_metrics_dict.type_ref_hash }}',
'performance': {{label.get_finance_output_metrics_dict.performance|floatformat:"2" }},
'current_price': {{label.get_finance_output_metrics_dict.current_price|floatformat:"2" }},
'fiat_value_old': {{label.get_finance_output_metrics_dict.fiat_value_old|floatformat:"2" }}, // historical or tracked value
'is_tracked': {{label.get_finance_output_metrics_dict.is_tracked|lower }} // tracked value
'fiat_value_old': {{label.get_finance_output_metrics_dict.fiat_value_old|floatformat:"2" }},
'is_tracked': {{label.get_finance_output_metrics_dict.is_tracked|lower }},
{% if action == "fee-efficiency" %}
'health_status': '{{ label.get_fee_health_status.status }}',
'health_display': '{{ label.get_fee_health_status_display }}',
'health_fee_sats': {{ label.get_fee_health_status.fee_sats|default:"null" }},
'health_fee_percentage': {{ label.get_fee_health_status.fee_percentage|default:"null" }},
{% endif %}
},
{% else %}
{% endif %}
{% endfor %}
];
// Sorting the array by the 'sats' property in descending order
window.records.sort(function(a, b) {
return b.sats - a.sats;
});
var ctx = document.getElementById("chart-area").getContext("2d");
window.chart1 = new Chart(ctx, {
@ -133,10 +167,18 @@ data: {
data: window.records,
key: 'sats',
backgroundColor: function(ctx) {
return red_or_greem_fromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex);
{% if action == "fee-efficiency" %}
return healthColorFromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex);
{% else %}
return red_or_greem_fromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex);
{% endif %}
},
borderColor: function(ctx) {
return red_or_greem_fromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex, true);
{% if action == "fee-efficiency" %}
return healthColorFromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex, true);
{% else %}
return red_or_greem_fromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex, true);
{% endif %}
},
spacing: 1,
borderWidth: 0,
@ -148,7 +190,7 @@ options: {
maintainAspectRatio: false,
title: {
display: true,
text: "Unspent {% if action == "unspent-spendable-outputs" %}Spendable {% endif %}Outputs"
text: "{% if action == 'fee-efficiency' %}Fee Efficiency (VTER){% elif action == 'unspent-spendable-outputs' %}Unspent Spendable Outputs{% else %}Unspent Outputs{% endif %}"
},
legend: {
display: false
@ -160,8 +202,6 @@ options: {
}
});
document.getElementById("chart-area").onclick = function(evt) {
var activePoints = window.chart1.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true);
if (activePoints[0]) {
@ -169,28 +209,26 @@ document.getElementById("chart-area").onclick = function(evt) {
}
};
// Track the current popover index
window.curr_popover_index = -1;
// Track the current popover index
window.curr_popover_index = -1;
// Define popover content
function getPopoverContent(index) {
if (index >= 0 && index < window.records.length) {
/*
var is_spent = "no";
if (window.records[index].spent) {
is_spent = "yes";
}
var is_spendable = "no";
if (window.records[index].spendable) {
is_spendable = "yes";
}
*/
var content = '<div class="popover-content">';
content += '<div><strong>Label:</strong> ' + window.records[index].label + '</div>';
content += '<div><strong>Output:</strong> ' + window.records[index].ref + '</div>';
{% if action == "fee-efficiency" %}
// Add health information for fee-efficiency view
if (window.records[index].health_status) {
content += '<div><strong>Fee Health:</strong> ' + window.records[index].health_display + '</div>';
if (window.records[index].health_fee_sats !== null) {
content += '<div><strong>Estimated Fee:</strong> ' + window.records[index].health_fee_sats + ' sats</div>';
}
}
{% endif %}
content += '<div><strong>Confirmed in block:</strong> ' + window.records[index].confirmed_at_block_height + '</div>';
if (window.records[index].is_tracked) {
content += '<div><strong>Tracked value:</strong> ' + window.records[index].fiat_cur + ' ' + window.records[index].fiat_value_old + '</div>';
@ -238,16 +276,13 @@ document.body.onmousemove = function (evt) {
var topPosition = evt.pageY - popoverHeight / 2;
var leftPosition;
if (evt.pageX <= element.getBoundingClientRect().left + element.offsetWidth / 2) {
// Inside the left half of chart-area, keep popover on the right side of the evt.pageX
leftPosition = evt.pageX + 10;
} else {
// Inside the right half of chart-area, keep popover on the left side of the evt.pageX (posx - popoverWidth = new pos x)
leftPosition = evt.pageX - popoverWidth - 10;
}
leftPosition = Math.min(leftPosition, window.innerWidth - popoverWidth - 10); // Ensure the popover doesn't exceed the right screen border
leftPosition = Math.max(leftPosition, 10); // Ensure the popover doesn't exceed the left screen border
leftPosition = Math.min(leftPosition, window.innerWidth - popoverWidth - 10);
leftPosition = Math.max(leftPosition, 10);
// Ensure that the top position is within the viewport
if (topPosition < 0) {
topPosition = 0;
} else if (topPosition + popoverHeight > window.innerHeight) {
@ -279,13 +314,10 @@ document.getElementById("chart-area").onmouseout = function (evt) {
</div>
{% else %}
NO LABELBASE
{% endif %}
<script>
{% addtoblock "js" %}
$(document).ready(function () {

View file

@ -69,16 +69,7 @@
<h4>Privacy</h4>
<table class="table">
<tbody>
<tr>
<td style="width:21%;"><b>Chatwoot</b></td>
<td style="width:75%;"><small>Enable or disable <a href="https://www.chatwoot.com/">Chatwoot</a> support chat. Your interactions may be subject to their
<a href="https://www.chatwoot.com/terms-of-service">terms of service</a> and <a href="https://www.chatwoot.com/privacy-policy">privacy policy</a>.</small></td>
<td>
<div class="form-check form-switch">
<input class="form-check-input user-setting" type="checkbox" {% if request.user.profile.use_chatwoot %}checked="checked"{% endif %} id="use_chatwoot">
</div>
</td>
</tr>
<tr>
<td style="width:21%;"><b>Sentry</b></td>
<td style="width:75%;"><small>Enable or disable anonymized error tracking.</small></td>
@ -105,7 +96,7 @@
<input class="form-check-input user-setting" type="checkbox" {% if request.user.profile.update_utxo_on_login %}checked="checked"{% endif %} id="update_utxo_on_login">
</div>
</td>
</tr>
</table>

View file

@ -17,6 +17,10 @@
{% url 'userprofile_mempool' as mempool_url %}
<a class="nav-link {% if request.path == mempool_url %}active" aria-current="page" {% else %}"{% endif %} href="{{ mempool_url }}">Mempool</a>
</li>
<li class="nav-item">
{% url 'userprofile_fees' as mempool_fees_url %}
<a class="nav-link {% if request.path == mempool_fees_url %}active" aria-current="page" {% else %}"{% endif %} href="{{ mempool_fees_url }}">Mempool Fees</a>
</li>
{% if request.user.profile.use_fiatfinances %}
<li class="nav-item">
{% url 'userprofile_currency' as my_currency_url %}

View file

@ -0,0 +1,42 @@
{% extends "_base.html" %}
{% load i18n %}
{% load sekizai_tags %}
{% load bootstrap %}
{% block content %}
{% include "profile_header_menu.html" %}
<div style="padding-top: 2rem; ">
{% block profile_content %}{% endblock %}
</div>
<p class="fs-6 text-muted">
<!-- Select your fee for new transactions. The suggestion shown is taken from your mempool instance.-->
Define the fee for new transactions. This will be used to evaluate the health of your unspent transaction outputs.
<br><br>
<!--(fee/amount < 0.01)<br>-->
🟢 Healthy fee: < {{ request.user.profile.my_fee_threshold_healthy }}%
🟡 Warning: {{ request.user.profile.my_fee_threshold_healthy }}-{{ user.profile.my_fee_threshold_warning }}%
🔴 High: > {{ request.user.profile.my_fee_threshold_warning }}%
<form action="" method="POST">
{% csrf_token %}
<table>
{{ form|bootstrap }}
<tr>
<td>&nbsp;</td>
<td></td>
</tr>
<tr>
<td colspan="2" style="text-align: center;">
<button class="btn btn-primary" type="submit">Update fee</button>
</td>
</tr>
</table>
</form>
</p>
{% endblock %}

View file

@ -41,4 +41,11 @@ class ProfileCurrencyForm(forms.ModelForm):
fields = ['my_currency']
class ProfileFeeForm(forms.ModelForm):
class Meta:
model = Profile
fields = ['my_fee', "my_fee_rate_threshold"]
#

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2025-12-02 22:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('userprofile', '0018_profile_use_attachments'),
]
operations = [
migrations.AddField(
model_name='profile',
name='my_fee',
field=models.IntegerField(default=1),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2025-12-02 22:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('userprofile', '0019_profile_my_fee'),
]
operations = [
migrations.AddField(
model_name='profile',
name='my_fee_rate_threshold',
field=models.IntegerField(default=5),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.25 on 2025-12-06 08:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('userprofile', '0020_profile_my_fee_rate_threshold'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='my_fee',
field=models.IntegerField(default=1, help_text='Fee used when broadcasting transactions'),
),
migrations.AlterField(
model_name='profile',
name='my_fee_rate_threshold',
field=models.IntegerField(default=1, help_text='Overwrite healthy fee'),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2025-12-19 19:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('userprofile', '0021_auto_20251206_0811'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='my_fee_rate_threshold',
field=models.IntegerField(default=1, help_text='Adjust fee health thresholds (percentage points). Default: 0 = no adjustment, +1 = more lenient, -1 = more strict'),
),
]

View file

@ -49,7 +49,21 @@ class Profile(models.Model):
choices=CURRENCY_CHOICES,
default='USD')
my_fee = models.IntegerField(default=1, help_text="Fee used when broadcasting transactions")
my_fee_rate_threshold = models.IntegerField(default=1, help_text="Adjust fee health thresholds (percentage points). Default: 0 = no adjustment, +1 = more lenient, -1 = more strict")
has_seen_welcome_popup = models.BooleanField(default=False)
@property
def my_fee_threshold_healthy(self):
"""Calculate healthy threshold with user adjustment"""
return 1.0 + self.my_fee_rate_threshold
@property
def my_fee_threshold_warning(self):
"""Calculate warning threshold with user adjustment"""
return 3.0 + self.my_fee_rate_threshold
def labelbases(self):
return Labelbase.objects.filter(user_id=self.user_id)

View file

@ -11,7 +11,8 @@ from .forms import (
ProfileAvatarForm,
ProfileCurrencyForm,
ElectrumServerInfoForm,
MempoolForm)
MempoolForm,
ProfileFeeForm)
@ -135,3 +136,19 @@ class ProfileCurrencyUpdateView(UpdateView):
def form_valid(self, form):
messages.success(self.request, "<strong>Success!</strong> Currency updated successfully.")
return super().form_valid(form)
class ProfileFeeUpdateView(UpdateView):
model = Profile
form_class = ProfileFeeForm
template_name = 'profile_update_fees.html'
def get_success_url(self):
return reverse_lazy('userprofile_fees')
def get_object(self, queryset=None):
return get_object_or_404(Profile, user=self.request.user)
def form_valid(self, form):
messages.success(self.request, "<strong>Success!</strong> Fees updated successfully.")
return super().form_valid(form)

View file

@ -9,6 +9,7 @@ services:
- MYSQL_USER=ulabelbase
- MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD
- MYSQL_PASSWORD=$MYSQL_PASSWORD
- MYSQL_HOST=labelbase_mysql
ports:
- "3306:3306"
volumes:
@ -39,6 +40,8 @@ services:
- MYSQL_DATABASE=labelbase
- MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD
- MYSQL_PASSWORD=$MYSQL_PASSWORD
- MYSQL_HOST=labelbase_mysql
- MYSQL_PORT=3306
command: /app/run.sh
labelbase_nginx:
build:

View file

@ -1,3 +1,11 @@
FROM nginx:latest
# Use official nginx image with explicit platform support
FROM --platform=$BUILDPLATFORM nginx:alpine
COPY nginx.conf /etc/nginx/
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port 8080
EXPOSE 8080
# Use the default nginx entrypoint
CMD ["nginx", "-g", "daemon off;"]