mirror of
https://github.com/Labelbase/Labelbase.git
synced 2026-08-13 12:33:23 +02:00
Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7bd019f0e | ||
|
|
e4faeff358 | ||
|
|
16fecc6afa | ||
|
|
b13e1846db | ||
|
|
748b9d6cd8 | ||
|
|
86f971b066 | ||
|
|
e9cd749151 | ||
|
|
f18bce10b0 | ||
|
|
82394dec51 | ||
|
|
fff7ac548c | ||
|
|
b5c0b7531f | ||
|
|
177a74aac1 | ||
|
|
f8008fd6f4 | ||
|
|
2f517a3ef8 |
47 changed files with 3805 additions and 285 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,6 +5,7 @@ labelbase.log
|
|||
labelbase.log.*
|
||||
bgt.log
|
||||
db/
|
||||
backup_*
|
||||
*.pyc
|
||||
__pycache__
|
||||
django/importer/uploadeddata/*
|
||||
|
|
|
|||
896
BACKUP_AND_MIGRATION_GUIDE.md
Normal file
896
BACKUP_AND_MIGRATION_GUIDE.md
Normal 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
357
BARE_METALL_INSTALL.md
Normal 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
312
DEVELOPMENT_GUIDE.md
Normal 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`
|
||||
|
|
@ -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
223
django/finances/tx_math.py
Normal 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()
|
||||
48
django/finances/tx_math_tests.py
Normal file
48
django/finances/tx_math_tests.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
54
django/labelbase/migrations/0012_auto_20251122_0800.py
Normal file
54
django/labelbase/migrations/0012_auto_20251122_0800.py
Normal 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)),
|
||||
),
|
||||
]
|
||||
|
|
@ -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']}%"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
7
django/labelbase/static/js/bootstrap.bundle.min.js
vendored
Normal file
7
django/labelbase/static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
django/labelbase/static/js/feather.min.js
vendored
Normal file
13
django/labelbase/static/js/feather.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
django/labelbase/static/js/jquery-3.5.1.min.js
vendored
Normal file
2
django/labelbase/static/js/jquery-3.5.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
django/labelbase/static/js/jquery.dataTables.min.js
vendored
Normal file
4
django/labelbase/static/js/jquery.dataTables.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
django/labelbase/static/js/qrcode.min.js
vendored
Normal file
1
django/labelbase/static/js/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
django/labelbase/static/zxing.js
Normal file
1
django/labelbase/static/zxing.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
212
django/templates/currency_sync.html
Normal file
212
django/templates/currency_sync.html
Normal 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 %}
|
||||
128
django/templates/fill_missing_data.html
Normal file
128
django/templates/fill_missing_data.html
Normal 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 %}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -168,6 +168,8 @@
|
|||
<script>
|
||||
{% addtoblock "js" %}
|
||||
|
||||
|
||||
|
||||
$(document).ready(function() {
|
||||
$('.open-modal').click(function() {
|
||||
var label = $(this).data('label');
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 < {{ 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 > {{ 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 () {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
42
django/templates/profile_update_fees.html
Normal file
42
django/templates/profile_update_fees.html
Normal 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> </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 %}
|
||||
|
|
@ -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"]
|
||||
|
||||
|
||||
#
|
||||
|
|
|
|||
18
django/userprofile/migrations/0019_profile_my_fee.py
Normal file
18
django/userprofile/migrations/0019_profile_my_fee.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
23
django/userprofile/migrations/0021_auto_20251206_0811.py
Normal file
23
django/userprofile/migrations/0021_auto_20251206_0811.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue