mirror of
https://github.com/Labelbase/Labelbase.git
synced 2026-08-13 12:33:23 +02:00
Compare commits
33 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7bd019f0e | ||
|
|
e4faeff358 | ||
|
|
16fecc6afa | ||
|
|
b13e1846db | ||
|
|
748b9d6cd8 | ||
|
|
86f971b066 | ||
|
|
e9cd749151 | ||
|
|
f18bce10b0 | ||
|
|
82394dec51 | ||
|
|
fff7ac548c | ||
|
|
b5c0b7531f | ||
|
|
177a74aac1 | ||
|
|
f8008fd6f4 | ||
|
|
2f517a3ef8 | ||
|
|
d74b99d05f | ||
|
|
3a26ef3399 | ||
|
|
cb5a782c13 | ||
|
|
086bee5745 | ||
|
|
82173c7052 | ||
|
|
62bb797663 | ||
|
|
d5f7256ec5 | ||
|
|
7133b3012b | ||
|
|
e4b9497eb5 | ||
|
|
9ead6bc75e | ||
|
|
eb1118ed8b | ||
|
|
b0b88a156a | ||
|
|
37b2a1b988 | ||
|
|
7a79c66f09 | ||
|
|
8eca90567d | ||
|
|
8ade222821 | ||
|
|
06ef65f183 | ||
|
|
7fc66c3309 | ||
|
|
bca2be7c3b |
98 changed files with 6132 additions and 909 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 \
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ def bg_runner(proxy_task, task=None, loop=None, *args, **kwargs):
|
|||
task = task_qs[0]
|
||||
if func is None:
|
||||
raise BackgroundTaskError("Function is None, can't execute!")
|
||||
print("bg_runner, loop {}".format(loop))
|
||||
kwargs['loop'] = loop
|
||||
func(*args, **kwargs)
|
||||
|
||||
|
|
@ -96,8 +95,6 @@ class Tasks(object):
|
|||
return _decorator
|
||||
|
||||
def run_task(self, task_name, loop, args=None, kwargs=None):
|
||||
print("run_task loop {}".format(loop))
|
||||
# task_name can be either the name of a task or a Task instance.
|
||||
if isinstance(task_name, Task):
|
||||
task = task_name
|
||||
task_name = task.task_name
|
||||
|
|
|
|||
|
|
@ -5,5 +5,8 @@ register = template.Library()
|
|||
|
||||
@register.simple_tag
|
||||
def is_label_id_in_queue(label_id):
|
||||
return Task.objects.filter(task_name="finances.tasks.check_spent",
|
||||
task_params__contains=label_id).exists()
|
||||
try:
|
||||
return Task.objects.filter(task_name="finances.tasks.check_spent",
|
||||
task_params__contains=label_id).exists()
|
||||
except:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -2,7 +2,16 @@ from django.contrib import admin
|
|||
from .models import OutputStat, HistoricalPrice
|
||||
|
||||
class OutputStatAdmin(admin.ModelAdmin):
|
||||
list_display = ('type_ref_hash', 'value', 'confirmed_at_block_height', 'confirmed_at_block_time', 'get_spent_status', 'spent', 'network', 'user')
|
||||
list_display = ('type_ref_hash',
|
||||
'value',
|
||||
'confirmed_at_block_height',
|
||||
'confirmed_at_block_time',
|
||||
'get_spent_status',
|
||||
'spent',
|
||||
'network',
|
||||
'user',
|
||||
'next_enc_input_attrs',
|
||||
'last_error')
|
||||
list_filter = ('network', 'spent')
|
||||
search_fields = ('type_ref_hash',)
|
||||
ordering = ('-confirmed_at_block_time',)
|
||||
|
|
|
|||
|
|
@ -1,36 +1,32 @@
|
|||
from connectrum.client import StratumClient
|
||||
from connectrum.svr_info import ServerInfo
|
||||
from connectrum import ElectrumErrorResponse
|
||||
|
||||
|
||||
from labelbase.models import Label
|
||||
from finances.models import OutputStat, HistoricalPrice
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger('labelbase')
|
||||
|
||||
logger = logging.getLogger('labelbase')
|
||||
|
||||
async def interact(conn, server_info, method, utxo):
|
||||
try:
|
||||
await conn.connect(server_info, "s", use_tor=server_info.is_onion,
|
||||
disable_cert_verify=True, short_term=True)
|
||||
disable_cert_verify=True, short_term=True)
|
||||
txid, index = utxo.split(":")
|
||||
try:
|
||||
txn = await conn.RPC(method, txid, True)
|
||||
if txn:
|
||||
try:
|
||||
blocktime = int(txn.get('blocktime', 0))
|
||||
logger.debug("blocktime: {}".format(blocktime))
|
||||
logger.debug(f"blocktime: {blocktime}")
|
||||
except Exception as ex:
|
||||
blocktime = 0
|
||||
logger.error("Can't get blocktime: {}".format(ex))
|
||||
logger.error(f"Can't get blocktime: {ex}")
|
||||
utxo = txn.get('vout')[int(index)]
|
||||
address = txn.get('vout')[int(index)].get('scriptPubKey', {}).get('address')
|
||||
value = txn.get('vout')[int(index)].get('value')*100000000
|
||||
return (txid, index, address, value, blocktime, utxo)
|
||||
value = txn.get('vout')[int(index)].get('value') * 100000000
|
||||
return txid, index, address, value, blocktime, utxo
|
||||
except ElectrumErrorResponse as ex:
|
||||
logger.error("ERROR: {} {}".format(ex, conn.last_error))
|
||||
|
||||
logger.error(f"ERROR: {ex} {conn.last_error}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -38,198 +34,117 @@ async def interact(conn, server_info, method, utxo):
|
|||
async def interact_addr(conn, server_info, method, addr):
|
||||
try:
|
||||
await conn.connect(server_info, "s", use_tor=server_info.is_onion,
|
||||
disable_cert_verify=True, short_term=True)
|
||||
disable_cert_verify=True, short_term=True)
|
||||
try:
|
||||
hextx = await conn.RPC(method, addr)
|
||||
if hextx is not None:
|
||||
print(hextx)
|
||||
return hextx
|
||||
else:
|
||||
print("Failed to fetch transaction.")
|
||||
except ElectrumErrorResponse as ex:
|
||||
print(ex)
|
||||
logger.error(ex)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_valid_output_ref(ref):
|
||||
if not ref:
|
||||
return False
|
||||
if ":" in ref:
|
||||
return True
|
||||
return False
|
||||
return ":" in ref if ref else False
|
||||
|
||||
|
||||
def checkup_label(label_id, loop):
|
||||
if label_id and loop:
|
||||
try:
|
||||
elem = Label.objects.get(id=label_id)
|
||||
output = OutputStat.objects.filter(user=elem.labelbase.user,
|
||||
type_ref_hash=elem.type_ref_hash,
|
||||
network=elem.labelbase.network).last()
|
||||
if not output:
|
||||
output = OutputStat(user=elem.labelbase.user,
|
||||
type_ref_hash=elem.type_ref_hash,
|
||||
network=elem.labelbase.network, value=0)
|
||||
if not label_id or not loop:
|
||||
logger.error(f"Invalid input: label_id={label_id}, loop={loop}")
|
||||
return
|
||||
|
||||
if elem.type == "output" and is_valid_output_ref(elem.ref) and \
|
||||
(output.spent is not True or output.confirmed_at_block_time == 0):
|
||||
if elem.labelbase.is_mainnet:
|
||||
electrum_hostname = elem.labelbase.user.profile.electrum_hostname or "electrum.emzy.de"
|
||||
electrum_ports = elem.labelbase.user.profile.electrum_ports or "s50002"
|
||||
elif elem.labelbase.is_testnet:
|
||||
electrum_hostname = elem.labelbase.user.profile.electrum_hostname_test or "testnet.qtornado.com"
|
||||
electrum_ports = elem.labelbase.user.profile.electrum_ports_test or "s51002"
|
||||
server_info = ServerInfo(electrum_hostname, electrum_hostname, ports=(electrum_ports))
|
||||
|
||||
conn = StratumClient()
|
||||
utxo = elem.ref
|
||||
utxo_data = {}
|
||||
utxo_resp = loop.run_until_complete(interact(conn, server_info, "blockchain.transaction.get", utxo))
|
||||
|
||||
if utxo_resp:
|
||||
txid, index, address, value, blocktime, utxo_data = utxo_resp
|
||||
if utxo_data:
|
||||
output.next_input_attributes = utxo_data
|
||||
if blocktime:
|
||||
HistoricalPrice.get_or_create_from_api(timestamp=blocktime)
|
||||
|
||||
try:
|
||||
unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.address.listunspent", address))
|
||||
except:
|
||||
conn.last_error = None # reset error if needed
|
||||
unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.scripthash.listunspent", address))
|
||||
|
||||
|
||||
|
||||
|
||||
utxo_value = 0
|
||||
utxo_height = 0
|
||||
|
||||
if unspents:
|
||||
for unspent in unspents:
|
||||
if unspent.get('tx_hash') == txid and \
|
||||
unspent.get('tx_pos') == int(index) and \
|
||||
unspent.get('height') > 0 and \
|
||||
unspent.get('value') > 0: # Output is confirmed, but not spent yet
|
||||
output.spent = False
|
||||
utxo_value = unspent.get('value')
|
||||
utxo_height = unspent.get('height')
|
||||
|
||||
output.network = elem.labelbase.network
|
||||
if utxo_height:
|
||||
output.confirmed_at_block_height = utxo_height
|
||||
if blocktime:
|
||||
output.confirmed_at_block_time = blocktime
|
||||
if utxo_value:
|
||||
output.value = utxo_value
|
||||
elif value:
|
||||
output.value = value
|
||||
break
|
||||
#
|
||||
elif conn.last_error:
|
||||
output.last_error = conn.last_error
|
||||
else:
|
||||
output.last_error = {}
|
||||
output.save()
|
||||
try:
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing label {}: {}".format(label_id, e))
|
||||
else:
|
||||
if not label_id:
|
||||
logger.error("Can't get label_id! {}".format(label_id))
|
||||
if not loop:
|
||||
logger.error("Can't get loop!")
|
||||
|
||||
|
||||
def checkup_label_buggy(label_id, loop):
|
||||
if label_id and loop:
|
||||
try:
|
||||
elem = Label.objects.get(id=label_id)
|
||||
output = OutputStat.objects.filter(user=elem.labelbase.user,
|
||||
type_ref_hash=elem.type_ref_hash,
|
||||
network=elem.labelbase.network).last()
|
||||
output = OutputStat.objects.filter(
|
||||
user=elem.labelbase.user,
|
||||
type_ref_hash=elem.type_ref_hash,
|
||||
network=elem.labelbase.network
|
||||
).last()
|
||||
|
||||
if not output:
|
||||
print("Creating OutputStat")
|
||||
output = OutputStat(user=elem.labelbase.user,
|
||||
type_ref_hash=elem.type_ref_hash,
|
||||
network=elem.labelbase.network, value=0)
|
||||
print("Using OutputStat id {}".format(output))
|
||||
print("elem.type {} {} {} {}".format(elem.type, is_valid_output_ref(elem.ref), elem.ref, output.spent))
|
||||
if elem.type == "output" and is_valid_output_ref(elem.ref) and \
|
||||
(output.spent is not True or output.confirmed_at_block_time == 0):
|
||||
electrum_hostname = elem.labelbase.user.profile.electrum_hostname
|
||||
if not electrum_hostname:
|
||||
electrum_hostname = "electrum.emzy.de"
|
||||
electrum_ports = elem.labelbase.user.profile.electrum_ports
|
||||
if not electrum_ports:
|
||||
electrum_ports = "s50002"
|
||||
print("going for server_info")
|
||||
server_info = ServerInfo(electrum_hostname, electrum_hostname, ports=((electrum_ports)))
|
||||
print("server_info: {}".format(server_info))
|
||||
output = OutputStat(
|
||||
user=elem.labelbase.user,
|
||||
type_ref_hash=elem.type_ref_hash,
|
||||
network=elem.labelbase.network,
|
||||
value=0,
|
||||
spent=None,
|
||||
confirmed_at_block_height=0,
|
||||
confirmed_at_block_time=0
|
||||
)
|
||||
output.save()
|
||||
logger.debug(f"Output before processing: {output.output_metrics_dict()}")
|
||||
|
||||
if elem.type == "output" and is_valid_output_ref(elem.ref) and (
|
||||
output.spent is not True or output.confirmed_at_block_time is None
|
||||
):
|
||||
# Determine server info based on network
|
||||
if elem.labelbase.is_mainnet:
|
||||
electrum_hostname = elem.labelbase.user.profile.electrum_hostname or "fulcrum.sethforprivacy.com"
|
||||
electrum_ports = elem.labelbase.user.profile.electrum_ports or "s50002"
|
||||
elif elem.labelbase.is_testnet:
|
||||
electrum_hostname = elem.labelbase.user.profile.electrum_hostname_test or "testnet.qtornado.com"
|
||||
electrum_ports = elem.labelbase.user.profile.electrum_ports_test or "s51002"
|
||||
else:
|
||||
raise ValueError("Unknown network type.")
|
||||
|
||||
server_info = ServerInfo(electrum_hostname, electrum_hostname, ports=(electrum_ports))
|
||||
conn = StratumClient()
|
||||
assert elem.type_ref_hash
|
||||
utxo = elem.ref
|
||||
tx_hash, tx_pos = elem.ref.split(":")
|
||||
|
||||
# Fetch transaction details
|
||||
utxo_resp = loop.run_until_complete(interact(conn, server_info, "blockchain.transaction.get", utxo))
|
||||
blocktime = 0
|
||||
|
||||
if utxo_resp:
|
||||
print("utxo_resp {}".format(utxo_resp))
|
||||
txid, index, address, value, blocktime = utxo_resp
|
||||
txid, index, address, value, blocktime, utxo_data = utxo_resp
|
||||
logger.debug(f"Transaction {txid} fetched with blocktime {blocktime}")
|
||||
if blocktime:
|
||||
print("Found blocktime {} for label id {}.".format(blocktime, label_id))
|
||||
HistoricalPrice.get_or_create_from_api(timestamp=blocktime)
|
||||
output.confirmed_at_block_time = blocktime
|
||||
HistoricalPrice.get_or_create_from_api(None, timestamp=blocktime)
|
||||
|
||||
# Fetch all unspents for the address
|
||||
try:
|
||||
unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.address.listunspent", address))
|
||||
except:
|
||||
conn.last_error = None # reset error if needed
|
||||
conn.last_error = None
|
||||
unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.scripthash.listunspent", address))
|
||||
|
||||
unspent_utxo = False
|
||||
utxo_value = 0
|
||||
utxo_height = 0
|
||||
print("unspents: {}".format(unspents))
|
||||
logger.debug(f"Unspents for address {address}: {unspents}")
|
||||
|
||||
if unspents:
|
||||
for unspent in unspents:
|
||||
if unspent.get('tx_hash') == tx_hash and \
|
||||
unspent.get('tx_pos') == int(tx_pos) and \
|
||||
unspent.get('height') > 0 and \
|
||||
unspent.get('value') > 0:
|
||||
unspent_utxo = True
|
||||
utxo_value = unspent.get('value')
|
||||
utxo_height = unspent.get('height')
|
||||
print("found unspent: {}".format(unspent))
|
||||
break
|
||||
if output:
|
||||
output.network = elem.labelbase.network
|
||||
if utxo_height:
|
||||
output.confirmed_at_block_height = utxo_height
|
||||
if blocktime:
|
||||
output.confirmed_at_block_time = blocktime
|
||||
if utxo_value:
|
||||
output.value = utxo_value
|
||||
elif value: # take value from TX
|
||||
output.value = value
|
||||
if unspent_utxo:
|
||||
utxo_found = False
|
||||
for unspent in unspents:
|
||||
if unspent.get('tx_hash') == txid and unspent.get('tx_pos') == int(index):
|
||||
output.spent = False
|
||||
else:
|
||||
output.spent = True
|
||||
output.last_error = {}
|
||||
output.value = unspent.get('value', None)
|
||||
output.confirmed_at_block_height = unspent.get('height', None)
|
||||
utxo_found = True
|
||||
|
||||
# Ensure all key details are stored
|
||||
output.network = elem.labelbase.network
|
||||
if unspent.get('height'):
|
||||
output.confirmed_at_block_height = unspent.get('height')
|
||||
if blocktime:
|
||||
output.confirmed_at_block_time = blocktime
|
||||
if unspent.get('value') is not None:
|
||||
output.value = unspent.get('value')
|
||||
elif value:
|
||||
output.value = value
|
||||
break
|
||||
|
||||
if not utxo_found:
|
||||
output.spent = True
|
||||
logger.warning(f"UTXO {txid}:{index} not found in unspent outputs.")
|
||||
|
||||
elif conn.last_error:
|
||||
# Damn...
|
||||
output.last_error = conn.last_error
|
||||
else:
|
||||
output.last_error = {}
|
||||
logger.warning(f"Unknown error occurred for UTXO {utxo}")
|
||||
output.last_error = {"error": "Unknown issue"}
|
||||
|
||||
logger.debug(f"Output after processing (before save): {output.output_metrics_dict()}")
|
||||
output.save()
|
||||
print("output id {} saved".format(output.id))
|
||||
try:
|
||||
conn.close()
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
if not label_id:
|
||||
logger.error("Can't get label_id! {}".format(label_id))
|
||||
if not loop:
|
||||
logger.error("Can't get loop!")
|
||||
output.refresh_from_db()
|
||||
logger.debug(f"Output after saving: {output.output_metrics_dict()}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing label {label_id}: {e}")
|
||||
|
|
|
|||
22
django/finances/migrations/0012_auto_20240701_0932.py
Normal file
22
django/finances/migrations/0012_auto_20240701_0932.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 3.2.25 on 2024-07-01 09:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('finances', '0011_alter_outputstat_network'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='outputstat',
|
||||
name='next_input_attributes',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='outputstat',
|
||||
name='next_enc_input_attrs',
|
||||
field=models.TextField(default=None, null=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import requests
|
||||
from django.db import models
|
||||
from django.contrib import messages
|
||||
from djmoney.models.fields import MoneyField
|
||||
from decimal import Decimal
|
||||
import datetime
|
||||
|
|
@ -7,9 +8,9 @@ from pymempool import MempoolAPI
|
|||
from labelbase.receivers import compute_type_ref_hash
|
||||
from django.conf import settings
|
||||
from jsonfield import JSONField
|
||||
import json
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
|
||||
from shared.encryption import get_fernet_key, cipher_suite
|
||||
import logging
|
||||
logger = logging.getLogger('labelbase')
|
||||
|
||||
|
|
@ -42,7 +43,20 @@ class OutputStat(models.Model):
|
|||
confirmed_at_block_time = models.IntegerField(default=0)
|
||||
|
||||
last_error = JSONField(default={})
|
||||
next_input_attributes = JSONField(default={}) # will be used for fee estimation
|
||||
next_enc_input_attrs = models.TextField(default=None, null=True) # will be used for fee estimation
|
||||
|
||||
def set_next_input_attributes(self, data):
|
||||
json_data = json.dumps(data).encode('utf-8')
|
||||
encrypted_data = cipher_suite.encrypt(json_data)
|
||||
self.next_enc_input_attrs = encrypted_data.decode('utf-8')
|
||||
|
||||
def next_input_attributes(self):
|
||||
if self.next_enc_input_attrs:
|
||||
encrypted_data = self.next_enc_input_attrs.encode('utf-8')
|
||||
decrypted_data = cipher_suite.decrypt(encrypted_data)
|
||||
return json.loads(decrypted_data.decode('utf-8'))
|
||||
return json.loads("{}")
|
||||
|
||||
MAINNET = 'mainnet'
|
||||
TESTNET = 'testnet'
|
||||
|
||||
|
|
@ -63,12 +77,12 @@ class OutputStat(models.Model):
|
|||
|
||||
@property
|
||||
def get_spent_status(self):
|
||||
if self.confirmed_at_block_time == 0:
|
||||
return "unconfirmed"
|
||||
if self.spent:
|
||||
return "spent"
|
||||
if not self.spent:
|
||||
elif not self.spent:
|
||||
return "unspent"
|
||||
elif self.confirmed_at_block_time == 0:
|
||||
return "unconfirmed"
|
||||
|
||||
def output_metrics_dict(self, tracked_fiat_value=0, fiat_currency="USD"):
|
||||
"""
|
||||
|
|
@ -88,8 +102,8 @@ class OutputStat(models.Model):
|
|||
# Check if the block time is confirmed
|
||||
if self.confirmed_at_block_time:
|
||||
# Get or create HistoricalPrice instance for the confirmed block time
|
||||
obj, created = HistoricalPrice.get_or_create_from_api(
|
||||
timestamp=self.confirmed_at_block_time
|
||||
obj, created = HistoricalPrice.get_or_create_from_api(self.user,
|
||||
timestamp=self.confirmed_at_block_time
|
||||
)
|
||||
if obj is None:
|
||||
logger.error("No price info found for {}".format(self.confirmed_at_block_time))
|
||||
|
|
@ -112,8 +126,8 @@ class OutputStat(models.Model):
|
|||
timestamp = int(current_datetime.timestamp())
|
||||
|
||||
# Get or create HistoricalPrice instance for the current time in UTC
|
||||
obj_now, created = HistoricalPrice.get_or_create_from_api(
|
||||
timestamp=timestamp
|
||||
obj_now, created = HistoricalPrice.get_or_create_from_api(self.user,
|
||||
timestamp=timestamp
|
||||
)
|
||||
|
||||
# Calculate the current price
|
||||
|
|
@ -144,7 +158,7 @@ class OutputStat(models.Model):
|
|||
Parses 'tracked_fiat_value' and 'fiat_currency' information from the given label.
|
||||
"""
|
||||
if self.confirmed_at_block_time:
|
||||
obj, created = HistoricalPrice.get_or_create_from_api(
|
||||
obj, created = HistoricalPrice.get_or_create_from_api(self.user,
|
||||
timestamp=self.confirmed_at_block_time)
|
||||
|
||||
performance = 0
|
||||
|
|
@ -192,13 +206,11 @@ class OutputStat(models.Model):
|
|||
network=network).last()
|
||||
|
||||
if cached_data:
|
||||
print("found cached data {} for type_ref_hash {}, created {}".format(cached_data, type_ref_hash, created))
|
||||
return cached_data, False
|
||||
|
||||
def get_value_and_spent(txid, vout):
|
||||
mempool_api = MempoolAPI()
|
||||
res0 = mempool_api.get_transaction(txid)
|
||||
print("res0 {}".format(res0))
|
||||
vouts = res0.get("vout", [])
|
||||
if vouts:
|
||||
value = vouts[int(vout)].get("value", 0)
|
||||
|
|
@ -210,10 +222,9 @@ class OutputStat(models.Model):
|
|||
|
||||
if txid and vout:
|
||||
res = get_value_and_spent(txid, vout)
|
||||
print (res)
|
||||
|
||||
if res:
|
||||
value, spent, confirmed_at_block_height, confirmed_at_block_time = res
|
||||
print("called data {} {} for type_ref_hash {}".format(value, spent, type_ref_hash))
|
||||
obj, created = cls.objects.get_or_create(user=user,
|
||||
type_ref_hash=type_ref_hash, network=network,
|
||||
defaults={
|
||||
|
|
@ -263,19 +274,33 @@ class HistoricalPrice(models.Model):
|
|||
|
||||
class Meta:
|
||||
ordering = ['-timestamp']
|
||||
|
||||
|
||||
@classmethod
|
||||
def get_or_create_from_api(cls, timestamp=-1):
|
||||
print("running get_or_create_from_api @ timestamp {}".format(timestamp))
|
||||
def get_or_create_from_api(cls, user=None, timestamp=-1):
|
||||
if timestamp == -1:
|
||||
current_datetime = datetime.datetime.now()
|
||||
timestamp = int(current_datetime.timestamp())
|
||||
cached_data = cls.objects.filter(timestamp=timestamp).first()
|
||||
if cached_data:
|
||||
return cached_data, False
|
||||
url = f"https://mempool.space/api/v1/historical-price?timestamp={timestamp}"
|
||||
response = requests.get(url)
|
||||
api_response = response.json()
|
||||
try:
|
||||
if user:
|
||||
mempool_endpoint = user.profile.mempool_endpoint
|
||||
else:
|
||||
mempool_endpoint = "https://mempool.space"
|
||||
url = f"{mempool_endpoint}/api/v1/historical-price?timestamp={timestamp}"
|
||||
response = requests.get(url)
|
||||
api_response = response.json()
|
||||
except Exception as ex:
|
||||
logger.error(ex, exc_info=True)
|
||||
try:
|
||||
from threadlocals.threadlocals import get_current_request
|
||||
request = get_current_request()
|
||||
if request:
|
||||
messages.error(request, "<strong>Connection Error:</strong> Could not connect to Mempool to retrieve historical price.")
|
||||
except Exception as ex2:
|
||||
logger.error(ex2, exc_info=True)
|
||||
return None, None
|
||||
try:
|
||||
obj, created = cls.objects.get_or_create(timestamp=timestamp, defaults={
|
||||
'usd_price': Decimal(str(api_response['prices'][0]['USD'])),
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.contrib import messages
|
||||
from django.dispatch import receiver
|
||||
from finances.models import HistoricalPrice
|
||||
|
||||
from django.contrib import messages
|
||||
|
||||
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
def perform_tasks_on_login(sender, user, request, **kwargs):
|
||||
""" """
|
||||
if user.profile.update_utxo_on_login:
|
||||
from finances.tasks import check_all_outputs
|
||||
from labelbase.models import Label
|
||||
check_all_outputs(user.id)
|
||||
if Label.objects.filter(labelbase__user_id=user.id).exists():
|
||||
messages.info(request, "<strong>Sync in progress:</strong> We are checking your unspent transaction outputs now.")
|
||||
try:
|
||||
if user.profile.update_utxo_on_login:
|
||||
from finances.tasks import check_all_outputs
|
||||
from labelbase.models import Label
|
||||
check_all_outputs(user.id)
|
||||
if Label.objects.filter(labelbase__user_id=user.id).exists():
|
||||
messages.info(request, (
|
||||
"<strong>Sync in progress:</strong> "
|
||||
"We are checking your unspent transaction outputs now."
|
||||
))
|
||||
except Exception as ex:
|
||||
messages.info(request, (
|
||||
"<strong>Oups:</strong> "
|
||||
f"{ex}"
|
||||
))
|
||||
|
||||
# Store nearest price information.
|
||||
HistoricalPrice.get_or_create_from_api(-1)
|
||||
HistoricalPrice.get_or_create_from_api(user, -1)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import logging
|
||||
from background_task import background
|
||||
from background_task.management.commands.remove_completed import _remove_completed_task
|
||||
|
||||
|
||||
from labelbase.models import Label
|
||||
from finances.electrum import checkup_label
|
||||
|
||||
|
|
|
|||
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()
|
||||
|
|
@ -8,3 +8,8 @@ class UploadFileForm(forms.Form):
|
|||
choices=IMPORTER_CHOICES
|
||||
)
|
||||
file = forms.FileField()
|
||||
passphrase = forms.CharField(
|
||||
widget=forms.PasswordInput(),
|
||||
required=False,
|
||||
max_length=100
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,15 +2,18 @@ from django.db import models
|
|||
from django.contrib.auth.models import User
|
||||
from labelbase.models import Labelbase
|
||||
from uuid_upload_path import upload_to
|
||||
from django.conf import settings
|
||||
|
||||
IMPORTER_CHOICES = (
|
||||
IMPORTER_CHOICES = [
|
||||
("BIP-0329", "BIP-329 .jsonl"),
|
||||
# TODO: ("BIP-0329-7z-enc" , "BIP-329 (encrypted) .7z"),
|
||||
("csv-bluewallet", "BlueWallet .csv"),
|
||||
("csv-bitbox", "BitBox .csv"),
|
||||
("pocket-accointing", "Pocket Accointing .csv")
|
||||
)
|
||||
("pocket-accointing", "Pocket Accointing .csv"),
|
||||
]
|
||||
|
||||
if settings.SELF_HOSTED:
|
||||
IMPORTER_CHOICES.append(("samourai", "Samourai .txt, (v2)"))
|
||||
|
||||
class UploadedData(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import csv
|
||||
import json
|
||||
from labelbase.models import Label
|
||||
from labelbase.models import Labelbase
|
||||
import json
|
||||
|
||||
|
||||
def validate_csv_format(csv_file_path):
|
||||
|
|
|
|||
151
django/importer/samourai.py
Normal file
151
django/importer/samourai.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import json
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Hash import SHA256
|
||||
from Crypto.Protocol.KDF import PBKDF2
|
||||
import hashlib
|
||||
import re
|
||||
import base64
|
||||
|
||||
from labelbase.serializers import LabelSerializer
|
||||
from labelbase.models import Label
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger('labelbase')
|
||||
|
||||
DefaultPBKDF2Iterations = 5000
|
||||
DefaultPBKDF2HMACSHA256Iterations = 15000
|
||||
DefaultSamouraiImportLabel = "Imported form samourai.txt"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def decrypt_v1(payload, password, iterations=DefaultPBKDF2Iterations):
|
||||
# V1 uses PBKDF2 for key derivation and AES for decryption
|
||||
AESBlockSize = 16
|
||||
cipherdata = base64.b64decode(payload)
|
||||
iv = cipherdata[:AESBlockSize]
|
||||
input_data = cipherdata[AESBlockSize:]
|
||||
key = PBKDF2(password, iv, dkLen=32, count=iterations)
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(input_data)
|
||||
return decrypted.rstrip(b"\x00").decode('utf-8')
|
||||
|
||||
|
||||
def decrypt_v2(payload, password, iterations=DefaultPBKDF2HMACSHA256Iterations):
|
||||
# V2 uses SHA256 for key derivation and AES for decryption
|
||||
encrypted_bytes = base64.b64decode(payload.replace("\n", ""))
|
||||
salt = encrypted_bytes[8:16]
|
||||
cipher_text = encrypted_bytes[16:]
|
||||
key_iv = PBKDF2(password, salt, dkLen=48, count=iterations, hmac_hash_module=SHA256)
|
||||
key = key_iv[:32]
|
||||
iv = key_iv[32:]
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(cipher_text)
|
||||
pad_len = decrypted[-1]
|
||||
decrypted = decrypted[:-pad_len]
|
||||
return decrypted.decode('utf-8')
|
||||
|
||||
|
||||
def import_samourai_labels(labelbase, content, passphrase):
|
||||
content = content.decode('utf-8')
|
||||
pattern = re.compile(r'\{.*?\}')
|
||||
match = pattern.search(content)
|
||||
imported_lables = 0
|
||||
payload = None
|
||||
if match:
|
||||
json_content = match.group(0)
|
||||
try:
|
||||
logger.info(f"json_content {json_content}")
|
||||
data = json.loads(json_content)
|
||||
logger.info(f"data: {data}")
|
||||
version = data.get("version", 1)
|
||||
payload = data.get("payload", "")
|
||||
if payload:
|
||||
if version in [1, "1"]:
|
||||
decrypted_data = decrypt_v1(payload, passphrase)
|
||||
elif version in [2, "2"]:
|
||||
decrypted_data = decrypt_v2(payload, passphrase)
|
||||
else:
|
||||
logger.error(f"Unsupported backup version: {version}")
|
||||
raise ValueError(f"Unsupported backup version: {version}")
|
||||
logger.info(decrypted_data)
|
||||
samourai_data = json.loads(decrypted_data)
|
||||
logger.info(samourai_data)
|
||||
|
||||
"""
|
||||
DOC/KB: If the labelbase where you import your samourai.txt into, labelbase will set the fingerprint,
|
||||
"""
|
||||
labels = Label.objects.filter(labelbase__id=labelbase.id)
|
||||
if labels.count() == 0:
|
||||
if not labelbase.fingerprint:
|
||||
labelbase.fingerprint = samourai_data.get('wallet').get('fingerprint')
|
||||
if samourai_data.get('wallet').get('testnet'):
|
||||
labelbase.network == labelbase.TESTNET
|
||||
else:
|
||||
labelbase.network == labelbase.MAINNET
|
||||
labelbase.save()
|
||||
|
||||
xpub = samourai_data.get('wallet', {}).get('accounts')[0].get('xpub')
|
||||
ypub = samourai_data.get('wallet', {}).get('bip49_accounts')[0].get('ypub')
|
||||
zpub = samourai_data.get('wallet', {}).get('bip84_accounts')[0].get('zpub')
|
||||
|
||||
for pub in [xpub, ypub, zpub]:
|
||||
if pub:
|
||||
_data = {
|
||||
"type": Label.TYPE_XPUB,
|
||||
"ref": pub,
|
||||
"label": DefaultSamouraiImportLabel,
|
||||
}
|
||||
_data["labelbase"] = labelbase.id
|
||||
serializer = LabelSerializer(data=_data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
imported_lables += 1
|
||||
|
||||
utxo_notes = samourai_data.get('meta', {}).get('utxo_notes')
|
||||
logger.info(utxo_notes)
|
||||
for note in utxo_notes:
|
||||
_data = {
|
||||
"type": Label.TYPE_TX,
|
||||
"ref": note[0],
|
||||
"label": note[1],
|
||||
}
|
||||
_data["labelbase"] = labelbase.id
|
||||
serializer = LabelSerializer(data=_data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
imported_lables += 1
|
||||
blocked_utxos = samourai_data.get('meta', {}).get('blocked_utxos',{}).get('blocked')
|
||||
logger.info(blocked_utxos)
|
||||
for utxo in blocked_utxos:
|
||||
_data = {
|
||||
"type": Label.TYPE_OUTPUT,
|
||||
"ref": utxo.get('id','').replace("-", ":"),
|
||||
"label": DefaultSamouraiImportLabel,
|
||||
"spendable": False
|
||||
}
|
||||
_data["labelbase"] = labelbase.id
|
||||
serializer = LabelSerializer(data=_data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
imported_lables += 1
|
||||
return imported_lables
|
||||
|
||||
else:
|
||||
print("No payload found in the JSON content.")
|
||||
logger.error("No payload found in the JSON content.")
|
||||
return imported_lables
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"JSONDecodeError: {e}")
|
||||
logger.error(f"JSONDecodeError: {e}")
|
||||
return imported_lables
|
||||
except Exception as ex:
|
||||
print(f"An error occurred: {ex}")
|
||||
logger.error(f"An error occurred: {ex}")
|
||||
logger.error(ex, exc_info=True)
|
||||
return imported_lables
|
||||
else:
|
||||
print("No JSON found in file.")
|
||||
logger.error("No JSON found in file.")
|
||||
return imported_lables
|
||||
|
|
@ -2,9 +2,10 @@ from background_task import background
|
|||
|
||||
import json
|
||||
import decimal
|
||||
from labelbase.models import Labelbase
|
||||
from labelbase.serializers import LabelSerializer
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger('labelbase')
|
||||
|
||||
from .models import UploadedData
|
||||
|
||||
|
|
@ -12,93 +13,135 @@ EOLSTOP = [b"", "", None, "\n"]
|
|||
|
||||
|
||||
@background(schedule=1)
|
||||
def process_uploaded_data(uploaded_data_id, loop=None):
|
||||
imported_lables = 0
|
||||
uploaded_data = UploadedData.objects.get(pk=uploaded_data_id)
|
||||
labelbase = uploaded_data.labelbase
|
||||
fp = uploaded_data.file.open()
|
||||
def process_uploaded_data(uploaded_data_id, passphrase=None, loop=None):
|
||||
try:
|
||||
uploaded_data = UploadedData.objects.get(pk=uploaded_data_id)
|
||||
imported_lables = 0
|
||||
labelbase = uploaded_data.labelbase
|
||||
fp = uploaded_data.file.open()
|
||||
|
||||
# BIP-0329
|
||||
if uploaded_data.import_type == "BIP-0329":
|
||||
while True:
|
||||
buf = fp.readline()
|
||||
if buf in EOLSTOP:
|
||||
break
|
||||
data = json.loads(buf)
|
||||
data["labelbase"] = labelbase.id
|
||||
serializer = LabelSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
imported_lables += 1
|
||||
elif uploaded_data.import_type == "BIP-0329-7z-enc":
|
||||
# TODO: Implementation needed.
|
||||
pass
|
||||
# Bitbox App
|
||||
elif uploaded_data.import_type == "csv-bitbox":
|
||||
while True:
|
||||
buf = fp.readline()
|
||||
if buf in EOLSTOP:
|
||||
break
|
||||
try:
|
||||
buf = str(buf.decode("utf-8"))
|
||||
sbuf = buf.split(",")
|
||||
# Time,Type,Amount,Unit,Fee,Address,Transaction ID,Note
|
||||
for elem in [("tx", 6), ("addr", 5)]:
|
||||
data = {
|
||||
"type": elem[0],
|
||||
"ref": sbuf[elem[1]],
|
||||
"label": " ".join(sbuf[7:]),
|
||||
}
|
||||
data["labelbase"] = labelbase.id
|
||||
serializer = LabelSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
imported_lables += 1
|
||||
else:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
'Could not process line "{}".'.format(buf),
|
||||
)
|
||||
except Exception as ex:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
'Could not process line "{}", {}.'.format(buf, ex),
|
||||
)
|
||||
# Pocket Accointing
|
||||
elif uploaded_data.import_type == "pocket-accointing":
|
||||
fp.close()
|
||||
csv_file_path = fp.name
|
||||
mempool_api = labelbase.get_mempool_api()
|
||||
from .pocket import validate_csv_format, parse_csv_to_json
|
||||
if validate_csv_format(csv_file_path):
|
||||
for item in parse_csv_to_json(csv_file_path):
|
||||
label = "Got {} {} for {:.2f} {} with reference: {} #Pocket".format(item[0].get('outSellAmount'), item[1].get(
|
||||
'inBuyAsset'), decimal.Decimal(item[2].get('inBuyAmount')), item[2].get('inBuyAsset'), item[1].get('operationId'))
|
||||
txid = item[0].get('operationId')
|
||||
tx = mempool_api.get_transaction(txid)
|
||||
potential_utxos = []
|
||||
vouts = tx.get("vout", [])
|
||||
for i in range(len(vouts)):
|
||||
if vouts[i].get('value', 0) == decimal.Decimal(item[0].get('outSellAmount'))*100000000:
|
||||
potential_utxos.append("{}:{}".format(txid, i ))
|
||||
data = {}
|
||||
if len(potential_utxos) == 1:
|
||||
# label UTXO/output of tx
|
||||
data = {
|
||||
"type": "output",
|
||||
"ref": potential_utxos[0],
|
||||
"label": label,
|
||||
}
|
||||
if len(potential_utxos) > 1:
|
||||
# mark tx, add warning tag
|
||||
# BIP-0329
|
||||
if uploaded_data.import_type == "BIP-0329":
|
||||
while True:
|
||||
buf = fp.readline()
|
||||
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():
|
||||
serializer.save()
|
||||
imported_lables += 1
|
||||
elif uploaded_data.import_type == "BIP-0329-7z-enc":
|
||||
# TODO: Implementation needed.
|
||||
pass
|
||||
elif uploaded_data.import_type == "samourai":
|
||||
buf = fp.read()
|
||||
logger.info(buf)
|
||||
print(buf)
|
||||
from .samourai import import_samourai_labels
|
||||
import_samourai_labels(labelbase, buf, passphrase)
|
||||
# Bitbox App
|
||||
elif uploaded_data.import_type == "csv-bitbox":
|
||||
while True:
|
||||
buf = fp.readline()
|
||||
if buf in EOLSTOP:
|
||||
break
|
||||
try:
|
||||
buf = str(buf.decode("utf-8"))
|
||||
sbuf = buf.split(",")
|
||||
# Time,Type,Amount,Unit,Fee,Address,Transaction ID,Note
|
||||
for elem in [("tx", 6), ("addr", 5)]:
|
||||
data = {
|
||||
"type": elem[0],
|
||||
"ref": sbuf[elem[1]],
|
||||
"label": " ".join(sbuf[7:]),
|
||||
}
|
||||
data["labelbase"] = labelbase.id
|
||||
serializer = LabelSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
imported_lables += 1
|
||||
else:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
'Could not process line "{}".'.format(buf),
|
||||
)
|
||||
except Exception as ex:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
'Could not process line "{}", {}.'.format(buf, ex),
|
||||
)
|
||||
# Pocket Accointing
|
||||
elif uploaded_data.import_type == "pocket-accointing":
|
||||
fp.close()
|
||||
csv_file_path = fp.name
|
||||
mempool_api = labelbase.get_mempool_api()
|
||||
from .pocket import validate_csv_format, parse_csv_to_json
|
||||
if validate_csv_format(csv_file_path):
|
||||
for item in parse_csv_to_json(csv_file_path):
|
||||
label = "Got {} {} for {:.2f} {} with reference: {} #Pocket".format(item[0].get('outSellAmount'), item[1].get(
|
||||
'inBuyAsset'), decimal.Decimal(item[2].get('inBuyAmount')), item[2].get('inBuyAsset'), item[1].get('operationId'))
|
||||
txid = item[0].get('operationId')
|
||||
tx = mempool_api.get_transaction(txid)
|
||||
potential_utxos = []
|
||||
vouts = tx.get("vout", [])
|
||||
for i in range(len(vouts)):
|
||||
if vouts[i].get('value', 0) == decimal.Decimal(item[0].get('outSellAmount'))*100000000:
|
||||
potential_utxos.append("{}:{}".format(txid, i ))
|
||||
data = {}
|
||||
if len(potential_utxos) == 1:
|
||||
# label UTXO/output of tx
|
||||
data = {
|
||||
"type": "output",
|
||||
"ref": potential_utxos[0],
|
||||
"label": label,
|
||||
}
|
||||
if len(potential_utxos) > 1:
|
||||
# mark tx, add warning tag
|
||||
data = {
|
||||
"type": "tx",
|
||||
"ref": txid,
|
||||
"label": "{} {}".format(label, "#W001_UTXO_NOT_FOUND"),
|
||||
}
|
||||
if data:
|
||||
data["labelbase"] = labelbase.id
|
||||
serializer = LabelSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
imported_lables += 1
|
||||
else:
|
||||
# messages.add_message(
|
||||
# request,
|
||||
# messages.ERROR,
|
||||
# 'Could not process record "{}".'.format(item),
|
||||
# )
|
||||
pass
|
||||
else:
|
||||
print("ERROR") # TODO
|
||||
# BlueWallet
|
||||
elif uploaded_data.import_type == "csv-bluewallet":
|
||||
header_row = True
|
||||
while True:
|
||||
buf = fp.readline()
|
||||
if buf in EOLSTOP:
|
||||
break
|
||||
if header_row:
|
||||
header_row = False
|
||||
continue
|
||||
try:
|
||||
buf = str(buf.decode("utf-8"))
|
||||
sbuf = buf.split(",")
|
||||
data = {
|
||||
"type": "tx",
|
||||
"ref": txid,
|
||||
"label": "{} {}".format(label, "#W001_UTXO_NOT_FOUND"),
|
||||
"ref": sbuf[1],
|
||||
"label": " ".join(sbuf[3:]),
|
||||
}
|
||||
if data:
|
||||
data["labelbase"] = labelbase.id
|
||||
serializer = LabelSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
|
|
@ -108,50 +151,20 @@ def process_uploaded_data(uploaded_data_id, loop=None):
|
|||
# messages.add_message(
|
||||
# request,
|
||||
# messages.ERROR,
|
||||
# 'Could not process record "{}".'.format(item),
|
||||
# 'Could not process line "{}".'.format(buf),
|
||||
# )
|
||||
pass
|
||||
else:
|
||||
print("ERROR") # TODO
|
||||
# BlueWallet
|
||||
elif uploaded_data.import_type == "csv-bluewallet":
|
||||
header_row = True
|
||||
while True:
|
||||
buf = fp.readline()
|
||||
if buf in EOLSTOP:
|
||||
break
|
||||
if header_row:
|
||||
header_row = False
|
||||
continue
|
||||
try:
|
||||
buf = str(buf.decode("utf-8"))
|
||||
sbuf = buf.split(",")
|
||||
data = {
|
||||
"type": "tx",
|
||||
"ref": sbuf[1],
|
||||
"label": " ".join(sbuf[3:]),
|
||||
}
|
||||
data["labelbase"] = labelbase.id
|
||||
serializer = LabelSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
imported_lables += 1
|
||||
else:
|
||||
except Exception as ex:
|
||||
# messages.add_message(
|
||||
# request,
|
||||
# messages.ERROR,
|
||||
# 'Could not process line "{}".'.format(buf),
|
||||
# 'Could not process line "{}", {}.'.format(buf, ex),
|
||||
# )
|
||||
pass
|
||||
except Exception as ex:
|
||||
# messages.add_message(
|
||||
# request,
|
||||
# messages.ERROR,
|
||||
# 'Could not process line "{}", {}.'.format(buf, ex),
|
||||
# )
|
||||
pass
|
||||
# Clean up – Note: Currently we delete the upload from the file system,
|
||||
# later we can store the messages.add_message messages, the state and the
|
||||
# amount of importet labels in it to propagate the messages to the
|
||||
# frontend/user interface.
|
||||
uploaded_data.delete()
|
||||
# Clean up – Note: Currently we delete the upload from the file system,
|
||||
# later we can store the messages.add_message messages, the state and the
|
||||
# amount of importet labels in it to propagate the messages to the
|
||||
# frontend/user interface.
|
||||
uploaded_data.delete()
|
||||
except Exception as ex:
|
||||
logger.error(ex, exc_info=True)
|
||||
|
|
|
|||
|
|
@ -10,27 +10,4 @@ def genericlabeluploadform(labelbase_id):
|
|||
form.fields["labelbase_id"].initial = labelbase_id
|
||||
form.fields["import_type"].initial = "BIP-0329"
|
||||
return form
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def bip0329labeluploadform(labelbase_id):
|
||||
form = UploadFileForm()
|
||||
form.fields["labelbase_id"].initial = labelbase_id
|
||||
form.fields["import_type"].initial = "BIP-0329"
|
||||
return form
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def csvBlueWalletlabeluploadform(labelbase_id):
|
||||
form = UploadFileForm()
|
||||
form.fields["labelbase_id"].initial = labelbase_id
|
||||
form.fields["import_type"].initial = "csv-bluewallet"
|
||||
return form
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def csvBitBoxLabeluploadform(labelbase_id):
|
||||
form = UploadFileForm()
|
||||
form.fields["labelbase_id"].initial = labelbase_id
|
||||
form.fields["import_type"].initial = "csv-bitbox"
|
||||
return form
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ def upload_labels(request):
|
|||
file=request.FILES["file"],
|
||||
)
|
||||
# Schedule the background task to process the uploaded data
|
||||
process_uploaded_data(uploaded_data.id)
|
||||
process_uploaded_data(uploaded_data.id, passphrase=form.cleaned_data.get("passphrase", None))
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.INFO,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ from labelbase.models import Labelbase, Label
|
|||
from labelbase.serializers import LabelbaseSerializer, LabelSerializer
|
||||
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger('labelbase')
|
||||
|
||||
class LabelbaseAPIView(APIView):
|
||||
"""
|
||||
Labelbase
|
||||
|
|
@ -90,7 +93,7 @@ class LabelAPIView(APIView):
|
|||
"spendable": request.data.get("spendable", "null"),
|
||||
|
||||
}
|
||||
|
||||
#logger.debug(f"data: {data}")
|
||||
serializer = LabelSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
|
|
|||
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
|
||||
|
||||
|
|
@ -130,7 +131,7 @@ class Label(models.Model):
|
|||
TYPE_PUBKEY = "pubkey"
|
||||
TYPE_INPUT = "input"
|
||||
TYPE_OUTPUT = "output"
|
||||
TYPE_XPUT = "xpub"
|
||||
TYPE_XPUB = "xpub"
|
||||
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_TX, "tx"),
|
||||
|
|
@ -138,7 +139,7 @@ class Label(models.Model):
|
|||
(TYPE_PUBKEY, "pubkey"),
|
||||
(TYPE_INPUT, "input"),
|
||||
(TYPE_OUTPUT, "output"),
|
||||
(TYPE_XPUT, "xpub"),
|
||||
(TYPE_XPUB, "xpub"),
|
||||
]
|
||||
|
||||
type = models.CharField(
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
BIN
django/labelbase/static/Cloud.png
Normal file
BIN
django/labelbase/static/Cloud.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
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:
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ sentry_sdk.init(
|
|||
traces_sample_rate=1.0,
|
||||
send_default_pii=True, # must be "True" here, will skip or omit in `before_send` callback
|
||||
)
|
||||
sentry_sdk.set_tag("version", "2.2.1")
|
||||
sentry_sdk.set_tag("version", "2.2.3")
|
||||
|
||||
|
||||
LOGGING = {
|
||||
|
|
@ -103,8 +103,11 @@ INSTALLED_APPS = [
|
|||
"django_otp",
|
||||
"django_otp.plugins.otp_static",
|
||||
"django_otp.plugins.otp_totp",
|
||||
|
||||
"labelbase",
|
||||
"userprofile",
|
||||
"notifications",
|
||||
|
||||
"bootstrapform",
|
||||
"cryptography",
|
||||
"rest_framework",
|
||||
|
|
@ -118,6 +121,8 @@ INSTALLED_APPS = [
|
|||
"hashtags",
|
||||
"statusapp",
|
||||
"attachments",
|
||||
"messages_extends",
|
||||
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -132,9 +137,13 @@ MIDDLEWARE = [
|
|||
# "django.middleware.cache.FetchFromCacheMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"threadlocals.middleware.ThreadLocalMiddleware",
|
||||
|
||||
|
||||
]
|
||||
|
||||
MESSAGE_STORAGE = 'messages_extends.storages.FallbackStorage'
|
||||
|
||||
|
||||
ROOT_URLCONF = "labellabor.urls"
|
||||
|
||||
|
|
@ -176,8 +185,11 @@ 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
|
||||
|
||||
|
||||
|
|
@ -37,6 +40,7 @@ from .views import (
|
|||
AboutView,
|
||||
EncryptionView,
|
||||
InteroperationalView,
|
||||
CloudView,
|
||||
ExportLabelsView,
|
||||
# StatsAndKPIView,
|
||||
TreeMapsView,
|
||||
|
|
@ -45,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
|
||||
|
||||
|
|
@ -92,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()),
|
||||
|
|
@ -149,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()),
|
||||
|
|
@ -262,6 +301,11 @@ urlpatterns = [
|
|||
InteroperationalView.as_view(),
|
||||
name="interoperational"
|
||||
),
|
||||
path(
|
||||
"cloud",
|
||||
CloudView.as_view(),
|
||||
name="cloud"
|
||||
),
|
||||
path(
|
||||
"outputstat/<int:output_stats_id>/update/<int:label_id>/",
|
||||
login_required(OutputStatUpdateRedirectView.as_view()),
|
||||
|
|
@ -295,6 +339,10 @@ urlpatterns = [
|
|||
"attachments/",
|
||||
include('attachments.urls', namespace='attachments')
|
||||
),
|
||||
path(
|
||||
"messages/",
|
||||
include("messages_extends.urls", "messages_extends")
|
||||
),
|
||||
path(
|
||||
"has_seen_welcome_popup/",
|
||||
has_seen_welcome_popup,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
@ -101,6 +103,22 @@ class BitcoinAddressDatatableView(BaseDatatableView):
|
|||
pub = key.derive(f"m/0/{idx}").key
|
||||
sc = script.p2wpkh(pub)
|
||||
address = sc.address(NETWORKS["main"])
|
||||
elif derivation == 'm/44' and xpub.startswith("tpub"):
|
||||
# BIP 44 - Legacy Addresses (P2PKH)
|
||||
pub = key.derive(f"m/0/{idx}").key
|
||||
sc = script.p2pkh(pub)
|
||||
address = sc.address(NETWORKS["test"])
|
||||
elif derivation == 'm/49' and xpub.startswith("upub"):
|
||||
# BIP 49 - SegWit Addresses (P2SH-P2WPKH)
|
||||
pub = key.derive(f"m/0/{idx}").key
|
||||
witness_script = script.p2wpkh(pub)
|
||||
sc = script.p2sh(witness_script)
|
||||
address = sc.address(NETWORKS["test"])
|
||||
elif derivation == 'm/84' and xpub.startswith("vpub"):
|
||||
# BIP 84 - Native SegWit Addresses (P2WPKH)
|
||||
pub = key.derive(f"m/0/{idx}").key
|
||||
sc = script.p2wpkh(pub)
|
||||
address = sc.address(NETWORKS["test"])
|
||||
else:
|
||||
continue
|
||||
|
||||
|
|
@ -171,6 +189,10 @@ class InteroperationalView(TemplateView):
|
|||
template_name = "interoperational.html"
|
||||
|
||||
|
||||
class CloudView(TemplateView):
|
||||
template_name = "cloud.html"
|
||||
|
||||
|
||||
class HomeView(TemplateView):
|
||||
template_name = "home.html"
|
||||
|
||||
|
|
@ -214,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
|
||||
|
|
@ -278,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 = []
|
||||
|
|
@ -295,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
|
||||
|
||||
|
||||
|
|
@ -522,9 +549,9 @@ class TreeMapsView(ListView):
|
|||
|
||||
|
||||
# Store nearest price information.
|
||||
# TODO: This should be done on new blocks.
|
||||
# TODO: This should (or could) be done on new blocks too.
|
||||
from finances.models import HistoricalPrice
|
||||
HistoricalPrice.get_or_create_from_api(-1)
|
||||
HistoricalPrice.get_or_create_from_api(self.request.user, -1)
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
|
|
@ -537,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 = []
|
||||
|
||||
|
|
@ -569,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"
|
||||
|
|
@ -702,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"]
|
||||
|
|
@ -761,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)
|
||||
|
|
@ -794,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
|
||||
|
|
@ -813,7 +1123,6 @@ class LabelUpdateView(UpdateView):
|
|||
return "label_derive_addresses.html"
|
||||
elif action == 'output-details':
|
||||
return "label_edit_output_details.html"
|
||||
|
||||
return "label_edit_update.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
|
@ -821,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
|
||||
|
|
@ -830,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
|
||||
|
||||
|
|
@ -897,6 +1224,13 @@ class ExampleSecretView(OTPRequiredMixin, TemplateView):
|
|||
|
||||
class OutputStatUpdateRedirectView(View):
|
||||
def get(self, request, output_stats_id, label_id):
|
||||
if not output_stats_id:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
"<strong>Hmmmm....</srong> Not ready yet. Please retry in a few seconds."
|
||||
)
|
||||
return redirect('edit_label', pk=label_id)
|
||||
try:
|
||||
output_stat = OutputStat.objects.get(id=output_stats_id)
|
||||
except OutputStat.DoesNotExist:
|
||||
|
|
@ -916,7 +1250,7 @@ class OutputStatUpdateRedirectView(View):
|
|||
)
|
||||
return redirect("home")
|
||||
|
||||
spent = request.GET.get('force-spent', None)
|
||||
spent = request.GET.get('force-spent', -1)
|
||||
|
||||
if spent not in ["true", "false", "none"]:
|
||||
messages.add_message(
|
||||
|
|
@ -939,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
|
||||
|
|
|
|||
268
django/messages_extends/README.md
Normal file
268
django/messages_extends/README.md
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
Django Messages Extends
|
||||
==========================
|
||||
|
||||
Adopted at commit https://github.com/AliLozano/django-messages-extends/commit/fe741794f3d36ab08d6f30c2750a9fbc2d14cbfc
|
||||
|
||||
|
||||
[](https://github.com/AliLozano/django-messages-extends/actions/workflows/test.yml)
|
||||
[](https://badge.fury.io/py/django-messages-extends)
|
||||
|
||||
|
||||
A Django app for extends Django's [messages framework](http://docs.djangoproject.com/en/dev/ref/contrib/messages/)
|
||||
by adding "sticky" and "persistent" backend message storages. This also supports the notion of sending
|
||||
persistent messages to other users in a machine-to-user process.
|
||||
|
||||
|
||||
## Storages ##
|
||||
|
||||
### Sticky Storage ###
|
||||
|
||||
A "sticky" message is a message where the user must hit the close button in order to get rid of
|
||||
it within that session.
|
||||
|
||||
* For messages that are in some middleware or is only to the current request and don't need save it.
|
||||
* This is very similar to the default except that you explicitly must close the dialog to remove
|
||||
the message.
|
||||
* This backend never save anything only simulate that do that.
|
||||
|
||||
|
||||
### Persistent Storage ###
|
||||
|
||||
A "persistent" messages is a message where a message is retained across multiple sessions until
|
||||
the user clicks the close button. The message is stored in the default_storage container
|
||||
(defaults to database).
|
||||
|
||||
* Only for authenticated users, messages are stored in the database.
|
||||
* The messages has to be explicit read, and there are show while don't close it
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
||||
This document assumes that you are familiar with Python and Django.
|
||||
|
||||
1. [Download and unzip the app](https://github.com/AliLozano/django-messages-extends),
|
||||
or install using `pip`:
|
||||
|
||||
$ pip install django-messages-extends
|
||||
|
||||
2. Make sure `messages_extends` is on your `PYTHONPATH`.
|
||||
3. Add `messages_extends` to your `INSTALLED_APPS` setting.
|
||||
|
||||
```python
|
||||
INSTALLED_APPS = (
|
||||
...
|
||||
'messages_extends',
|
||||
)
|
||||
```
|
||||
|
||||
4. Make sure Django's `MessageMiddleware` is in your `MIDDLEWARE_CLASSES` setting (which is the
|
||||
case by default):
|
||||
|
||||
```python
|
||||
MIDDLEWARE_CLASSES = (
|
||||
...
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
)
|
||||
```
|
||||
|
||||
5. Add the messages_extends URLs to your URL conf. For instance, in order to make messages
|
||||
available under `http://domain.com/messages/`, add the following line to `urls.py`.
|
||||
|
||||
```python
|
||||
urlpatterns = patterns('',
|
||||
(r'^messages/', include('messages_extends.urls')),
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
6. In your settings, set the message [storage backend](http://docs.djangoproject.com/en/dev/ref/contrib/messages/#message-storage-backends)to `messages_extends.storages.FallbackStorage`:
|
||||
|
||||
```python
|
||||
MESSAGE_STORAGE = 'messages_extends.storages.FallbackStorage'
|
||||
```
|
||||
|
||||
7. Set up the database tables using
|
||||
|
||||
$ manage.py makemigrations
|
||||
$ manage.py migrate
|
||||
|
||||
8. If you want to use the bundled templates, add the `templates` directory to your
|
||||
`TEMPLATE_DIRS` setting:
|
||||
|
||||
```python
|
||||
TEMPLATE_DIRS = (
|
||||
...
|
||||
'path/to/messages_extends/templates')
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
Using messages in views and templates
|
||||
-------------------------------------
|
||||
|
||||
### Message levels ###
|
||||
|
||||
Django's messages framework provides a number of [message levels](http://docs.djangoproject.com/en/dev/ref/contrib/messages/#message-levels)
|
||||
for various purposes such as success messages, warnings etc. This app provides constants with the
|
||||
same names, the difference being that messages with these levels are going to be persistent:
|
||||
|
||||
```python
|
||||
from messages_extends import constants as constants_messages
|
||||
|
||||
# default messages level
|
||||
constants_messages.DEBUG = 10
|
||||
constants_messages.INFO = 20
|
||||
constants_messages.SUCCESS = 25
|
||||
constants_messages.WARNING = 30
|
||||
constants_messages.ERROR = 40
|
||||
|
||||
# persistent messages level
|
||||
constants_messages.DEBUG_PERSISTENT = 9
|
||||
constants_messages.INFO_PERSISTENT = 19
|
||||
constants_messages.SUCCESS_PERSISTENT = 24
|
||||
constants_messages.WARNING_PERSISTENT = 29
|
||||
constants_messages.ERROR_PERSISTENT = 39
|
||||
|
||||
# sticky messages level
|
||||
constants_messages.DEBUG_STICKY = 8
|
||||
constants_messages.INFO_STICKY = 18
|
||||
constants_messages.SUCCESS_STICKY = 23
|
||||
constants_messages.WARNING_STICKY = 28
|
||||
constants_messages.ERROR_STICKY = 38
|
||||
```
|
||||
|
||||
### Adding a message ###
|
||||
|
||||
Since the app is implemented as a [storage backend](http://docs.djangoproject.com/en/dev/ref/contrib/messages/#message-storage-backends)
|
||||
for Django's [messages framework](http://docs.djangoproject.com/en/dev/ref/contrib/messages/), you
|
||||
can still use the regular Django API to add a message:
|
||||
|
||||
```python
|
||||
from django.contrib import messages
|
||||
messages.add_message(request, messages.INFO, 'Hello world.')
|
||||
```
|
||||
|
||||
Or use persistent messages with constants in messages_extends.constants
|
||||
|
||||
```python
|
||||
from django.contrib import messages
|
||||
from messages_extends import constants as constants_messages
|
||||
messages.add_message(request, constants_messages.WARNING_PERSISTENT, 'You are going to see this message until you mark it as read.')
|
||||
```
|
||||
|
||||
Or via the shortcut method.
|
||||
|
||||
```python
|
||||
messages.add_persistant_error(request, 'Houston we have a problem..')
|
||||
```
|
||||
|
||||
Note that this is only possible for logged-in users, so you are probably going to have make sure
|
||||
that the current user is not anonymous using `request.user.is_authenticated()`. Adding a
|
||||
persistent message for anonymous users raises a `NotImplementedError`.
|
||||
|
||||
And sticky messages:
|
||||
|
||||
```python
|
||||
from django.contrib import messages
|
||||
from messages_extends import constants as constants_messages
|
||||
messages.add_message(request, constants_messages.WARNING_STICKY, 'You will going to see this messages only in this request')
|
||||
```
|
||||
|
||||
You can also pass this function a `User` object if the message is supposed to be sent to a user
|
||||
other than the one who is currently authenticated. User Sally will see this message the next time
|
||||
she logs in:
|
||||
|
||||
```python
|
||||
from django.contrib import messages
|
||||
from messages_extends import constants as constants_messages
|
||||
from django.contrib.auth.models import User
|
||||
sally = User.objects.get(username='Sally')
|
||||
messages.add_message(request, constants_messages.INFO_PERSISTENT, "Hola abc desde %s" %request.user, user=sally)
|
||||
```
|
||||
|
||||
To persistent storages, there are other params like expires that is a datetime.
|
||||
|
||||
### Displaying messages ###
|
||||
|
||||
Messages can be displayed [as described in the Django manual](http://docs.djangoproject.com/en/dev/ref/contrib/messages/#displaying-messages).
|
||||
However, you are probably going to want to include links tags for closing each message (i.e.
|
||||
marking it as read). In your template, use something like:
|
||||
|
||||
```htmldjango
|
||||
{% for message in messages %}
|
||||
<div class="alert {% if message.tags %} alert-{{ message.tags }} {% endif %}">
|
||||
{# close-href is used because href is used by bootstrap to closing other divs #}
|
||||
<a class="close" data-dismiss="alert"{% if message.pk %} close-href="{% url message_mark_read message.pk %}"{% endif %}>×</a>
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
You can also use the bundled templates instead. The following line replaces the code above. It
|
||||
allows the user to remove messages using [bootstrap styling](http://twitter.github.com/bootstrap/)
|
||||
(you need use bootstrap.css and boostrap.js)
|
||||
|
||||
```htmldjango
|
||||
{% include "messages_extends/includes/alerts_bootstrap.html" %}
|
||||
```
|
||||
|
||||
For use Ajax to mark them as read you can add the following code that works with jquery:
|
||||
|
||||
```javascript
|
||||
$("a.close[close-href]").click(function (e) {
|
||||
e.preventDefault();
|
||||
$.post($(this).attr("close-href"), "", function () {
|
||||
});
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Or use:
|
||||
|
||||
```htmldjango
|
||||
<script src="{% static "close-alerts.js" %}"></script>
|
||||
```
|
||||
|
||||
DON'T FORGET: If you have CSRF enabled, you have to add csrf code by js, [see django Documentation](https://docs.djangoproject.com/en/dev/ref/csrf/#ajax)
|
||||
|
||||
If you don't want see close button in sticky alerts, you can use css for hide them:
|
||||
|
||||
```css
|
||||
.alert.sticky .close{
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
### Other Backends ###
|
||||
|
||||
You can use other backends, by default use:
|
||||
|
||||
```python
|
||||
MESSAGES_STORAGES = ('messages_extends.storages.StickyStorage',
|
||||
'messages_extends.storages.PersistentStorage',
|
||||
'django.contrib.messages.storage.cookie.CookieStorage',
|
||||
'django.contrib.messages.storage.session.SessionStorage'))
|
||||
```
|
||||
|
||||
But you can add or remove other backends in your settings in order that you need execute that,
|
||||
remember that session storagge save all messages, then you have to put it at final.
|
||||
|
||||
### Remember ###
|
||||
Remember that this module is only for messages from application, to messages between users you can
|
||||
use [postman](https://bitbucket.org/psam/django-postman) u other framework and to messages for
|
||||
activity stream you can use [django-activity-stream](https://github.com/justquick/django-activity-stream)
|
||||
|
||||
|
||||
## License ##
|
||||
|
||||
Django Messages Extends is provided under [The MIT License (MIT)](http://opensource.org/licenses/MIT).
|
||||
|
||||
|
||||
## Credits ##
|
||||
|
||||
Django Messages Extends is a project by [Ali Lozano](mailto:alilozanoc@gmail.com). Additional credit
|
||||
goes to:
|
||||
* [Steven Klass](sklass@pointcircle.com)
|
||||
|
||||
|
||||
Inspired and based in [django-persistent-messages](https://github.com/samluescher/django-persistent-messages)
|
||||
68
django/messages_extends/__init__.py
Normal file
68
django/messages_extends/__init__.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
from django.contrib.messages.api import MessageFailure
|
||||
from messages_extends.constants import *
|
||||
from django.contrib import messages
|
||||
|
||||
messages.DEFAULT_TAGS.update(DEFAULT_TAGS)
|
||||
|
||||
def add_message(request, level, message, extra_tags='', fail_silently=False, *args, **kwargs):
|
||||
"""
|
||||
Attempts to add a message to the request using the 'messages' app.
|
||||
"""
|
||||
if hasattr(request, '_messages'):
|
||||
return request._messages.add(level, message, extra_tags, *args, **kwargs)
|
||||
if not fail_silently:
|
||||
raise MessageFailure('You cannot add messages without installing '
|
||||
'django.contrib.messages.middleware.MessageMiddleware')
|
||||
|
||||
messages.add_message = add_message
|
||||
|
||||
|
||||
def persistant_debug(request, message, extra_tags='', fail_silently=False, *args, **kwargs):
|
||||
"""
|
||||
Adds a persistant message with the ``DEBUG`` level.
|
||||
"""
|
||||
add_message(request, DEBUG_PERSISTENT, message, extra_tags=extra_tags,
|
||||
fail_silently=fail_silently, *args, **kwargs)
|
||||
|
||||
messages.persistant_debug = persistant_debug
|
||||
|
||||
|
||||
def persistant_info(request, message, extra_tags='', fail_silently=False, *args, **kwargs):
|
||||
"""
|
||||
Adds a persistant message with the ``INFO`` level.
|
||||
"""
|
||||
add_message(request, INFO_PERSISTENT, message, extra_tags=extra_tags,
|
||||
fail_silently=fail_silently, *args, **kwargs)
|
||||
|
||||
messages.persistant_info = persistant_info
|
||||
|
||||
|
||||
def persistant_success(request, message, extra_tags='', fail_silently=False, *args, **kwargs):
|
||||
"""
|
||||
Adds a persistant message with the ``SUCCESS`` level.
|
||||
"""
|
||||
add_message(request, SUCCESS_PERSISTENT, message, extra_tags=extra_tags,
|
||||
fail_silently=fail_silently, *args, **kwargs)
|
||||
|
||||
messages.persistant_success = persistant_success
|
||||
|
||||
|
||||
def persistant_warning(request, message, extra_tags='', fail_silently=False, *args, **kwargs):
|
||||
"""
|
||||
Adds a persistant message with the ``WARNING`` level.
|
||||
"""
|
||||
add_message(request, WARNING_PERSISTENT, message, extra_tags=extra_tags,
|
||||
fail_silently=fail_silently, *args, **kwargs)
|
||||
|
||||
messages.persistant_warning = persistant_warning
|
||||
|
||||
|
||||
def persistant_error(request, message, extra_tags='', fail_silently=False, *args, **kwargs):
|
||||
"""
|
||||
Adds a persistant message with the ``ERROR`` level.
|
||||
"""
|
||||
add_message(request, ERROR_PERSISTENT, message, extra_tags=extra_tags,
|
||||
fail_silently=fail_silently, *args, **kwargs)
|
||||
|
||||
messages.persistant_error = persistant_error
|
||||
|
||||
10
django/messages_extends/admin.py
Normal file
10
django/messages_extends/admin.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""admin.py: messages extends"""
|
||||
|
||||
from messages_extends.models import Message
|
||||
from django.contrib import admin
|
||||
|
||||
class MessageAdmin(admin.ModelAdmin):
|
||||
list_display = ['level', 'user', 'message', 'created', 'read']
|
||||
|
||||
admin.site.register(Message, MessageAdmin)
|
||||
43
django/messages_extends/constants.py
Normal file
43
django/messages_extends/constants.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""constants.py: messages extends"""
|
||||
|
||||
|
||||
DEBUG = 10
|
||||
INFO = 20
|
||||
SUCCESS = 25
|
||||
WARNING = 30
|
||||
ERROR = 40
|
||||
|
||||
DEBUG_PERSISTENT = 11
|
||||
INFO_PERSISTENT = 21
|
||||
SUCCESS_PERSISTENT = 26
|
||||
WARNING_PERSISTENT = 31
|
||||
ERROR_PERSISTENT = 41
|
||||
|
||||
DEBUG_STICKY = 12
|
||||
INFO_STICKY = 22
|
||||
SUCCESS_STICKY = 27
|
||||
WARNING_STICKY = 32
|
||||
ERROR_STICKY = 42
|
||||
|
||||
DEFAULT_TAGS = {
|
||||
DEBUG_PERSISTENT: 'debug persistent',
|
||||
INFO_PERSISTENT: 'info persistent',
|
||||
SUCCESS_PERSISTENT: 'success persistent',
|
||||
WARNING_PERSISTENT: 'warning persistent',
|
||||
ERROR_PERSISTENT: 'error persistent',
|
||||
|
||||
DEBUG_STICKY: 'debug sticky',
|
||||
INFO_STICKY: 'info sticky',
|
||||
SUCCESS_STICKY: 'success sticky',
|
||||
WARNING_STICKY: 'warning sticky',
|
||||
ERROR_STICKY: 'error sticky',
|
||||
|
||||
}
|
||||
|
||||
PERSISTENT_MESSAGE_LEVELS = (
|
||||
DEBUG_PERSISTENT, INFO_PERSISTENT, SUCCESS_PERSISTENT, WARNING_PERSISTENT, ERROR_PERSISTENT
|
||||
)
|
||||
STICKY_MESSAGE_LEVELS = (
|
||||
DEBUG_STICKY, INFO_STICKY, SUCCESS_STICKY, WARNING_STICKY, ERROR_STICKY
|
||||
)
|
||||
12
django/messages_extends/exceptions.py
Normal file
12
django/messages_extends/exceptions.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""admin.py: messages extends"""
|
||||
|
||||
__author__ = 'ali'
|
||||
|
||||
class LevelOfMessageException(Exception):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LevelOfMessageException, self).__init__(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return "The level of the message, can't be proccess by this storage"
|
||||
27
django/messages_extends/migrations/0001_initial.py
Normal file
27
django/messages_extends/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from django.db import models, migrations
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Message',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('message', models.TextField()),
|
||||
('level', models.IntegerField(choices=[(11, 'PERSISTENT DEBUG'), (21, 'PERSISTENT INFO'), (26, 'PERSISTENT SUCCESS'), (31, 'PERSISTENT WARNING'), (41, 'PERSISTENT ERROR')])),
|
||||
('extra_tags', models.CharField(max_length=128)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('modified', models.DateTimeField(auto_now=True)),
|
||||
('read', models.BooleanField(default=False)),
|
||||
('expires', models.DateTimeField(null=True, blank=True)),
|
||||
('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)),
|
||||
],
|
||||
),
|
||||
]
|
||||
18
django/messages_extends/migrations/0002_alter_message_id.py
Normal file
18
django/messages_extends/migrations/0002_alter_message_id.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.25 on 2024-07-31 06:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('messages_extends', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='message',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
0
django/messages_extends/migrations/__init__.py
Normal file
0
django/messages_extends/migrations/__init__.py
Normal file
72
django/messages_extends/models.py
Normal file
72
django/messages_extends/models.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""models.py: messages extends"""
|
||||
|
||||
import messages_extends
|
||||
from django.db import models
|
||||
from django.utils.encoding import force_str
|
||||
from django.contrib.messages import utils
|
||||
from django.conf import settings
|
||||
|
||||
LEVEL_TAGS = utils.get_level_tags()
|
||||
|
||||
class Message(models.Model):
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True,
|
||||
on_delete=models.CASCADE)
|
||||
message = models.TextField()
|
||||
LEVEL_CHOICES = (
|
||||
(messages_extends.DEBUG_PERSISTENT, 'PERSISTENT DEBUG'),
|
||||
(messages_extends.INFO_PERSISTENT, 'PERSISTENT INFO'),
|
||||
(messages_extends.SUCCESS_PERSISTENT, 'PERSISTENT SUCCESS'),
|
||||
(messages_extends.WARNING_PERSISTENT, 'PERSISTENT WARNING'),
|
||||
(messages_extends.ERROR_PERSISTENT, 'PERSISTENT ERROR'),
|
||||
)
|
||||
level = models.IntegerField(choices=LEVEL_CHOICES)
|
||||
extra_tags = models.CharField(max_length=128)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
modified = models.DateTimeField(auto_now=True)
|
||||
read = models.BooleanField(default=False)
|
||||
expires = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
def __eq__(self, other):
|
||||
return isinstance(other, Message) and self.level == other.level and\
|
||||
self.message == other.message
|
||||
|
||||
__hash__ = models.Model.__hash__
|
||||
|
||||
def __str__(self):
|
||||
return force_str(self.message)
|
||||
|
||||
def _prepare_message(self):
|
||||
"""
|
||||
Prepares the message for saving by forcing the ``message``
|
||||
and ``extra_tags`` and ``subject`` to unicode in case they are lazy translations.
|
||||
|
||||
Known "safe" types (None, int, etc.) are not converted (see Django's
|
||||
``force_str`` implementation for details).
|
||||
"""
|
||||
self.message = force_str(self.message, strings_only=True)
|
||||
self.extra_tags = force_str(self.extra_tags, strings_only=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self._prepare_message()
|
||||
super(Message, self).save(*args, **kwargs)
|
||||
|
||||
def _get_tags(self):
|
||||
label_tag = force_str(LEVEL_TAGS.get(self.level, ''),
|
||||
strings_only=True)
|
||||
extra_tags = force_str(self.extra_tags, strings_only=True)
|
||||
|
||||
if self.read:
|
||||
read_tag = "read"
|
||||
else:
|
||||
read_tag = "unread"
|
||||
|
||||
if extra_tags and label_tag:
|
||||
return u' '.join([extra_tags, label_tag, read_tag])
|
||||
elif extra_tags:
|
||||
return u' '.join([extra_tags, read_tag])
|
||||
elif label_tag:
|
||||
return u' '.join([label_tag, read_tag])
|
||||
return read_tag
|
||||
|
||||
tags = property(_get_tags)
|
||||
9
django/messages_extends/static/close-alerts.js
Normal file
9
django/messages_extends/static/close-alerts.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
$(function() {
|
||||
$("a.close[close-href]").click(function (e) {
|
||||
e.preventDefault();
|
||||
$.post($(this).attr("close-href"), "", function () {
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
231
django/messages_extends/storages.py
Normal file
231
django/messages_extends/storages.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""storages.py: messages extends"""
|
||||
|
||||
from django.utils.module_loading import import_string as get_storage
|
||||
from django.contrib.messages.storage.base import BaseStorage, Message
|
||||
from django.conf import settings
|
||||
from messages_extends.models import Message as PersistentMessage
|
||||
from messages_extends.constants import PERSISTENT_MESSAGE_LEVELS, STICKY_MESSAGE_LEVELS
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db.models import Q
|
||||
try:
|
||||
from django.utils import timezone
|
||||
except ImportError:
|
||||
from datetime import datetime as timezone
|
||||
|
||||
__author__ = 'ali'
|
||||
|
||||
class FallbackStorage(BaseStorage):
|
||||
"""
|
||||
Tries to store all messages in the first backend, storing any unstored
|
||||
messages in each subsequent backend backend, by default use
|
||||
MESSAGES_STORAGES = ('messages_extends.storages.StickyStorage',
|
||||
'messages_extends.storages.PersistentStorage',
|
||||
'django.contrib.messages.storage.session.CookieStorage',
|
||||
'django.contrib.messages.storage.session.SessionStorage'))
|
||||
if you want change the backends, put your custom storages:
|
||||
MESSAGES_STORAGES = ('foo.your_storage', 'cookie_storage')
|
||||
in your settings
|
||||
"""
|
||||
|
||||
storages_names = getattr(settings, 'MESSAGES_STORAGES',
|
||||
('messages_extends.storages.StickyStorage',
|
||||
'messages_extends.storages.PersistentStorage',
|
||||
'django.contrib.messages.storage.cookie.CookieStorage',
|
||||
'django.contrib.messages.storage.session.SessionStorage'))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FallbackStorage, self).__init__(*args, **kwargs)
|
||||
|
||||
# get instances of classes of storages_names
|
||||
self.storages = [get_storage(storage)(*args, **kwargs)
|
||||
for storage in self.storages_names]
|
||||
|
||||
self._used_storages = set()
|
||||
|
||||
def _get(self, *args, **kwargs):
|
||||
"""
|
||||
Gets a single list of messages from all storage backends.
|
||||
"""
|
||||
all_messages = []
|
||||
for storage in self.storages:
|
||||
messages, all_retrieved = storage._get()
|
||||
# If the backend hasn't been used, no more retrieval is necessary.
|
||||
if messages is None:
|
||||
break
|
||||
if messages:
|
||||
self._used_storages.add(storage)
|
||||
all_messages.extend(messages)
|
||||
# If this storage class contained all the messages, no further
|
||||
# retrieval is necessary
|
||||
if all_retrieved:
|
||||
break
|
||||
return all_messages, all_retrieved
|
||||
|
||||
def _store(self, messages, response, *args, **kwargs):
|
||||
"""
|
||||
Stores the messages, returning any unstored messages after trying all
|
||||
backends.
|
||||
|
||||
For each storage backend, any messages not stored are passed on to the
|
||||
next backend.
|
||||
"""
|
||||
for storage in self.storages:
|
||||
if messages:
|
||||
messages = storage._store(messages, response,
|
||||
remove_oldest=False)
|
||||
# Even if there are no more messages, continue iterating to ensure
|
||||
# storages which contained messages are flushed.
|
||||
elif storage in self._used_storages:
|
||||
storage._store([], response)
|
||||
self._used_storages.remove(storage)
|
||||
return messages
|
||||
|
||||
|
||||
def add(self, level, message, extra_tags='', *args, **kwargs):
|
||||
"""
|
||||
Queues a message to be stored.
|
||||
|
||||
The message is only queued if it contained something and its level is
|
||||
not less than the recording level (``self.level``).
|
||||
"""
|
||||
if not message:
|
||||
return
|
||||
# Check that the message level is not less than the recording level.
|
||||
level = int(level)
|
||||
if level < self.level:
|
||||
return
|
||||
# Add the message
|
||||
self.added_new = True
|
||||
message = Message(level, message, extra_tags=extra_tags)
|
||||
for storage in self.storages:
|
||||
if hasattr(storage, 'process_message'):
|
||||
message = storage.process_message(message, *args, **kwargs)
|
||||
if not message:
|
||||
return
|
||||
self._queued_messages.append(message)
|
||||
|
||||
def _prepare_messages(self, messages):
|
||||
"""
|
||||
Prepares a list of messages for storage.
|
||||
"""
|
||||
for message in messages:
|
||||
if hasattr(message, '_prepare'):
|
||||
message._prepare()
|
||||
|
||||
|
||||
class PersistentStorage(BaseStorage):
|
||||
"""
|
||||
Save persistent messages in data base
|
||||
"""
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
self._sticky_messages = []
|
||||
super(PersistentStorage, self).__init__(request, *args, **kwargs)
|
||||
|
||||
def _message_queryset(self, include_read=False):
|
||||
"""
|
||||
Return a queryset of messages for the request user
|
||||
"""
|
||||
expire = timezone.now()
|
||||
|
||||
|
||||
qs = PersistentMessage.objects.\
|
||||
filter(user=self.get_user()).\
|
||||
filter(Q(expires=None) | Q(expires__gt=expire))
|
||||
if not include_read:
|
||||
qs = qs.exclude(read=True)
|
||||
return qs
|
||||
|
||||
|
||||
def _get(self, *args, **kwargs):
|
||||
"""
|
||||
Retrieves a list of stored messages. Returns a tuple of the messages
|
||||
and a flag indicating whether or not all the messages originally
|
||||
intended to be stored in this storage were, in fact, stored and
|
||||
retrieved; e.g., ``(messages, all_retrieved)``.
|
||||
"""
|
||||
is_authenticated = self.get_user().is_authenticated
|
||||
if callable(is_authenticated):
|
||||
is_authenticated = is_authenticated()
|
||||
if is_authenticated is not True:
|
||||
return [], False
|
||||
return self._message_queryset(), False
|
||||
|
||||
def _store(self, messages, response, *args, **kwargs):
|
||||
#There are alredy saved.
|
||||
return [message for message in messages if not message.level in PERSISTENT_MESSAGE_LEVELS]
|
||||
|
||||
def process_message(self, message, *args, **kwargs):
|
||||
"""
|
||||
If its level is into persist levels, convert the message to models and save it
|
||||
"""
|
||||
if not message.level in PERSISTENT_MESSAGE_LEVELS:
|
||||
return message
|
||||
|
||||
user = kwargs.get("user") or self.get_user()
|
||||
|
||||
try:
|
||||
anonymous = user.is_anonymous()
|
||||
except TypeError:
|
||||
anonymous = user.is_anonymous
|
||||
if anonymous:
|
||||
raise NotImplementedError('Persistent message levels cannot be used for anonymous users.')
|
||||
message_persistent = PersistentMessage()
|
||||
message_persistent.level = message.level
|
||||
message_persistent.message = message.message
|
||||
message_persistent.extra_tags = message.extra_tags
|
||||
message_persistent.user = user
|
||||
|
||||
if "expires" in kwargs:
|
||||
message_persistent.expires = kwargs["expires"]
|
||||
message_persistent.save()
|
||||
return None
|
||||
|
||||
def add(self, level, message, extra_tags='', *args, **kwargs):
|
||||
"""
|
||||
Queues a message to be stored.
|
||||
|
||||
The message is only queued if it contained something and its level is
|
||||
not less than the recording level (``self.level``).
|
||||
"""
|
||||
if not message:
|
||||
return
|
||||
# Check that the message level is not less than the recording level.
|
||||
level = int(level)
|
||||
if level < self.level:
|
||||
return
|
||||
# Add the message.
|
||||
self.added_new = True
|
||||
message = Message(level, message, extra_tags=extra_tags)
|
||||
message = self.process_message(message, *args, **kwargs)
|
||||
if message:
|
||||
self._queued_messages.append(message)
|
||||
|
||||
def get_user(self):
|
||||
if hasattr(self.request, 'user'):
|
||||
return self.request.user
|
||||
else:
|
||||
return AnonymousUser()
|
||||
|
||||
|
||||
class StickyStorage(BaseStorage):
|
||||
"""
|
||||
Keep messages that are sticky in memory
|
||||
"""
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super(StickyStorage, self).__init__(request, *args, **kwargs)
|
||||
|
||||
def _get(self, *args, **kwargs):
|
||||
"""
|
||||
Retrieves a list of messages from the memory.
|
||||
"""
|
||||
return [], False
|
||||
|
||||
def _store(self, messages, response, *args, **kwargs):
|
||||
"""
|
||||
Delete all messages that are sticky and return the other messages
|
||||
This storage never save objects
|
||||
"""
|
||||
return [message for message in messages if not message.level in STICKY_MESSAGE_LEVELS]
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{% for message in messages %}
|
||||
<div class="alert {% if message.tags %} alert-{{ message.tags }} {% endif %}">
|
||||
{# close-href is used because href is used by bootstrap to closing other divs #}
|
||||
<a class="close" data-dismiss="alert"{% if message.pk %} close-href="{% url "message_mark_read" message.pk %}"{% endif %}>×</a>
|
||||
{{ message|safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
12
django/messages_extends/urls.py
Normal file
12
django/messages_extends/urls.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""urls.py: messages extends"""
|
||||
|
||||
from django.urls import re_path, path
|
||||
from messages_extends.views import message_mark_all_read, message_mark_read
|
||||
|
||||
app_name = 'messages_extends'
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^mark_read/(?P<message_id>\d+)/$', message_mark_read, name='message_mark_read'),
|
||||
path('mark_read/all/', message_mark_all_read, name='message_mark_all_read'),
|
||||
]
|
||||
34
django/messages_extends/views.py
Normal file
34
django/messages_extends/views.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""views.py: messages extends"""
|
||||
|
||||
from messages_extends.models import Message
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
|
||||
def callable_or_bool(fn):
|
||||
if callable(fn):
|
||||
return fn()
|
||||
return fn
|
||||
|
||||
|
||||
def message_mark_read(request, message_id):
|
||||
if not callable_or_bool(request.user.is_authenticated):
|
||||
raise PermissionDenied
|
||||
message = get_object_or_404(Message, user=request.user, pk=message_id)
|
||||
message.read = True
|
||||
message.save()
|
||||
if not request.headers.get('x-requested-with') == 'XMLHttpRequest':
|
||||
return HttpResponseRedirect(request.META.get('HTTP_REFERER') or '/')
|
||||
else:
|
||||
return HttpResponse('')
|
||||
|
||||
def message_mark_all_read(request):
|
||||
if not callable_or_bool(request.user.is_authenticated):
|
||||
raise PermissionDenied
|
||||
Message.objects.filter(user=request.user).update(read=True)
|
||||
if not request.headers.get('x-requested-with') == 'XMLHttpRequest':
|
||||
return HttpResponseRedirect(request.META.get('HTTP_REFERER') or '/')
|
||||
else:
|
||||
return HttpResponse('')
|
||||
0
django/notifications/__init__.py
Normal file
0
django/notifications/__init__.py
Normal file
39
django/notifications/utils.py
Normal file
39
django/notifications/utils.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from django.contrib.auth.models import User
|
||||
from messages_extends.models import Message
|
||||
from messages_extends import constants
|
||||
"""
|
||||
DEBUG = 10
|
||||
INFO = 20
|
||||
SUCCESS = 25
|
||||
WARNING = 30
|
||||
ERROR = 40
|
||||
|
||||
DEBUG_PERSISTENT = 11
|
||||
INFO_PERSISTENT = 21
|
||||
SUCCESS_PERSISTENT = 26
|
||||
WARNING_PERSISTENT = 31
|
||||
ERROR_PERSISTENT = 41
|
||||
|
||||
DEBUG_STICKY = 12
|
||||
INFO_STICKY = 22
|
||||
SUCCESS_STICKY = 27
|
||||
WARNING_STICKY = 32
|
||||
ERROR_STICKY = 42
|
||||
|
||||
"""
|
||||
def notify_user(username, message, level=constants.SUCCESS_PERSISTENT):
|
||||
u = User.objects.get(username=username)
|
||||
m = Message(user=u, message=message, level=level)
|
||||
m.save()
|
||||
|
||||
def notify_success_persistent(username, message):
|
||||
notify_user(username, message, level=constants.SUCCESS_PERSISTENT)
|
||||
|
||||
def notify_warning_persistent(username, message):
|
||||
notify_user(username, message, level=constants.WARNING_PERSISTENT)
|
||||
|
||||
def notify_error_persistent(username, message):
|
||||
notify_user(username, message, level=constants.ERROR_PERSISTENT)
|
||||
|
||||
def notify_error(username, message):
|
||||
notify_user(username, message, level=constants.ERROR)
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
gunicorn
|
||||
asgiref==3.4.1
|
||||
certifi==2023.07.22
|
||||
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
|
||||
|
|
@ -39,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
|
||||
|
|
|
|||
0
django/shared/__init__.py
Normal file
0
django/shared/__init__.py
Normal file
31
django/shared/encryption.py
Normal file
31
django/shared/encryption.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.fernet import Fernet
|
||||
import base64
|
||||
import json
|
||||
from django.conf import settings
|
||||
|
||||
def get_fernet_key(secret_key, salt):
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=salt,
|
||||
iterations=100000,
|
||||
backend=default_backend()
|
||||
)
|
||||
key = base64.urlsafe_b64encode(kdf.derive(secret_key.encode()))
|
||||
return Fernet(key)
|
||||
|
||||
# Create a Fernet cipher suite using the derived key
|
||||
cipher_suite = get_fernet_key(settings.SECRET_KEY, settings.CRYPTOGRAPHY_SALT)
|
||||
|
||||
def encrypt_data(data):
|
||||
json_data = json.dumps(data).encode('utf-8')
|
||||
encrypted_data = cipher_suite.encrypt(json_data)
|
||||
return encrypted_data.decode('utf-8')
|
||||
|
||||
def decrypt_data(encrypted_data):
|
||||
encrypted_data_bytes = encrypted_data.encode('utf-8')
|
||||
decrypted_data = cipher_suite.decrypt(encrypted_data_bytes)
|
||||
return json.loads(decrypted_data.decode('utf-8'))
|
||||
|
|
@ -41,6 +41,26 @@
|
|||
{% render_block "css" %}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.search-container {
|
||||
position: relative;
|
||||
margin-top:6px;
|
||||
margin-bottom:6px;
|
||||
|
||||
}
|
||||
.search-button {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -70,7 +90,6 @@
|
|||
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3 fs-6" href="{% url 'home' %}" style="background-color: transparent;">
|
||||
<img style="width:210px" src="{% static 'labelbase-logo-white.png' %}">
|
||||
</a>
|
||||
|
||||
<div class="navbar-nav ms-auto">
|
||||
<div class="nav-item text-nowrap">
|
||||
{% if user.is_authenticated %}
|
||||
|
|
@ -84,6 +103,15 @@
|
|||
</div>
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<div class="search-container" style="margin-right: 16px; min-width: 21rem;">
|
||||
|
||||
<input type="text" id="searchInput" style="padding: .35rem !important; "
|
||||
placeholder="Search your Labelbases" aria-label="Search"
|
||||
class="form-control form-control-dark d-none d-md-flex">
|
||||
<button type="submit" id="searchButton" class="btn btn-primary search-button">Search</button>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="dropdown text-end d-none d-md-inline dd-avatar">
|
||||
<a href="#" class="d-block link-body-emphasis text-decoration-none dropdown-toggle dd-avatar"
|
||||
data-bs-toggle="dropdown" aria-expanded="true">
|
||||
|
|
@ -109,8 +137,22 @@
|
|||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</header>
|
||||
|
||||
<!-- small screen search -->
|
||||
<div class="d-block d-md-none px-3 py-2 bg-dark w-100">
|
||||
<div class="search-container w-100" >
|
||||
<input type="text" id="mobileSearchInput"
|
||||
placeholder="Search your Labelbases"
|
||||
aria-label="Search" class="form-control form-control-dark w-100">
|
||||
<button type="submit" id="mobileSearchButton" class="btn btn-primary search-button w-auto">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- search ends here -->
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
|
||||
|
|
@ -142,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 "labelbase_health" labelbase_id=labelbase.id %}"><span data-feather="activity" class="align-text-bottom"></span>UTXOs Health</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 %}
|
||||
|
|
@ -210,11 +253,9 @@
|
|||
<span>Community</span>
|
||||
</h6>
|
||||
|
||||
{% comment %}
|
||||
<li class="nav-item ">
|
||||
<a class="nav-link" href="{% url "donate" %}"><span data-feather="heart" class="align-text-bottom"></span> Donate</a>
|
||||
<li>
|
||||
{% endcomment %}
|
||||
|
||||
<li class="nav-item ">
|
||||
<a class="nav-link" href="https://labelbase.space/newsletter/"><span data-feather="mail" class="align-text-bottom"></span> Newsletter</a>
|
||||
|
|
@ -303,51 +344,51 @@
|
|||
|
||||
|
||||
{% 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");
|
||||
var searchButton = document.getElementById("searchButton");
|
||||
var mobileSearchInput = document.getElementById("mobileSearchInput");
|
||||
var mobileSearchButton = document.getElementById("mobileSearchButton");
|
||||
|
||||
function toggleButtonVisibility(input, button) {
|
||||
if (input.value.trim() !== "") {
|
||||
button.style.display = "block";
|
||||
} else {
|
||||
button.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
searchInput.addEventListener("input", function() {
|
||||
toggleButtonVisibility(searchInput, searchButton);
|
||||
});
|
||||
|
||||
mobileSearchInput.addEventListener("input", function() {
|
||||
toggleButtonVisibility(mobileSearchInput, mobileSearchButton);
|
||||
});
|
||||
|
||||
toggleButtonVisibility(searchInput, searchButton);
|
||||
toggleButtonVisibility(mobileSearchInput, mobileSearchButton);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
/* globals Chart:false, feather:false */
|
||||
|
|
@ -535,13 +576,6 @@
|
|||
loadChat(false);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
</div>
|
||||
<div class="overflow-auto scrollbar" style="height: 10rem;">
|
||||
<ul class="profile nav d-flex flex-column mb-2 pb-1">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_user_notifications %}{% endblock %}" href="JavaScript:void(0);" onclick="window.location='{% url 'userprofile' %}';"><span data-feather="bell" class="align-text-bottom"></span> Notifications</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% block nav_userprofile %}{% endblock %}" href="JavaScript:void(0);" onclick="window.location='{% url 'userprofile' %}';"><span data-feather="user" class="align-text-bottom"></span> Profile Settings</a>
|
||||
</li>
|
||||
|
|
@ -55,7 +59,7 @@
|
|||
</ul>
|
||||
</div>
|
||||
<div class="my-2 text-center"><small>
|
||||
<a class="text-600 me-1 termslink" href="JavaScript:void(0);" onclick="window.location='{% url 'privacy_policy' %}';">Privacy Policy</a>•<a class="text-600 mx-1 termslink" href="JavaScript:void(0);" onclick="window.location='{% url 'terms' %}';">Terms</a>•<a class="text-600 mx-1 termslink">v2.2.1</a></small>
|
||||
<a class="text-600 me-1 termslink" href="JavaScript:void(0);" onclick="window.location='{% url 'privacy_policy' %}';">Privacy Policy</a>•<a class="text-600 mx-1 termslink" href="JavaScript:void(0);" onclick="window.location='{% url 'terms' %}';">Terms</a>•<a class="text-600 mx-1 termslink">v2.2.3</a></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,30 +8,35 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
<div class="col float-end" >
|
||||
|
||||
<div class="btn-group float-end" role="group" style="padding-top: 2em;">
|
||||
<button type="button" class="rounded-start btn btn-sm btn-outline-secondary "
|
||||
|
||||
<!-- <button type="button" class="rounded-start btn btn-sm btn-outline-secondary "
|
||||
data-bs-toggle="modal" data-bs-target="#addLabelModal">New Label</button>
|
||||
<!--button type="button" class="rounded-start d-md-none btn btn-sm btn-outline-secondary " data-bs-toggle="modal" data-bs-target="#addLabelModal">New</button-->
|
||||
{% comment %}
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle " data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Import
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#importBip329LabelsModal">BIP-0329 Labels</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#importBlueWalletCSVLabelsModal">BlueWallet CSV History</a></li>
|
||||
<li><a class="dropdown-item" href="#" data-bs-toggle="modal" data-bs-target="#importBitboxCSVLabelsModal">BitBox App CSV History</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="{% url 'export_labels' labelbase.id %}" class="btn btn-sm btn-outline-secondary">Export</a>
|
||||
{% endcomment %}
|
||||
-->
|
||||
<button type="button" class="rounded-start btn btn-sm btn-outline-secondary d-md-none"
|
||||
data-bs-toggle="modal" data-bs-target="#addLabelModal"> New </button>
|
||||
<button type="button" class="rounded-start btn btn-sm btn-outline-secondary d-none d-md-inline"
|
||||
style="border-right-width: 0;"
|
||||
data-bs-toggle="modal" data-bs-target="#addLabelModal"> New Label </button>
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="btn-group" role="group">
|
||||
<!--button type="button" class="d-md-none btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Labels
|
||||
</button-->
|
||||
<button type="button" class="{#d-none d-md-block #} btn btn-sm btn-outline-secondary dropdown-toggle rounded-end me-2" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<!-- <button type="button" class="{#d-none d-md-block #} btn btn-sm btn-outline-secondary dropdown-toggle rounded-end me-2" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Label Actions
|
||||
</button>
|
||||
-->
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle rounded-end me-2 d-md-none"
|
||||
data-bs-toggle="dropdown" aria-expanded="false"> Actions </button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle rounded-end me-2 d-none d-md-inline"
|
||||
data-bs-toggle="dropdown" aria-expanded="false"> Label Actions </button>
|
||||
|
||||
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url "labelbase_actions" labelbase_id=labelbase.id action="update-spent-outputs" %}">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
{% load bootstrap %}
|
||||
|
||||
<!-- modal -->
|
||||
<div class="modal" tabindex="-1" id="addLabelModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
|
|
@ -14,13 +13,89 @@
|
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{{ labelform|bootstrap }}
|
||||
{{ labelform|bootstrap }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" id="startButton">Start Scanner</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Scanner Section -->
|
||||
<div id="scanner-obj" style="display: none; padding-top:1rem;">
|
||||
<div>
|
||||
<video id="video" width="100%" height="210"></video>
|
||||
</div>
|
||||
<div class="pmd-card-actions mt-2">
|
||||
<button type="button" class="btn btn-secondary" id="resetButton">Stop Scanner</button>
|
||||
</div>
|
||||
<div id="sourceSelectPanel" style="display:none" class="mt-2">
|
||||
<label for="sourceSelect">Change video source:</label>
|
||||
<select id="sourceSelect" style="max-width:400px"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" src="https://unpkg.com/@zxing/library@latest"></script>
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
$('#addLabelModal').on('shown.bs.modal', function () {
|
||||
const refInputField = document.getElementById('id_ref').parentElement;
|
||||
const scannerObj = document.getElementById('scanner-obj');
|
||||
refInputField.insertAdjacentElement('afterend', scannerObj);
|
||||
// Initialize QR scanner functionality
|
||||
let selectedDeviceId;
|
||||
const codeReader = new ZXing.BrowserMultiFormatReader();
|
||||
console.log('ZXing code reader initialized');
|
||||
codeReader.getVideoInputDevices().then((videoInputDevices) => {
|
||||
const sourceSelect = document.getElementById('sourceSelect');
|
||||
selectedDeviceId = videoInputDevices[0].deviceId;
|
||||
if (videoInputDevices.length >= 1) {
|
||||
videoInputDevices.forEach((element) => {
|
||||
const sourceOption = document.createElement('option');
|
||||
sourceOption.text = element.label;
|
||||
sourceOption.value = element.deviceId;
|
||||
sourceSelect.appendChild(sourceOption);
|
||||
});
|
||||
|
||||
sourceSelect.onchange = () => {
|
||||
selectedDeviceId = sourceSelect.value;
|
||||
};
|
||||
|
||||
const sourceSelectPanel = document.getElementById('sourceSelectPanel');
|
||||
sourceSelectPanel.style.display = 'block';
|
||||
}
|
||||
|
||||
document.getElementById('startButton').addEventListener('click', () => {
|
||||
codeReader.decodeFromVideoDevice(selectedDeviceId, 'video', (result, err) => {
|
||||
if (result) {
|
||||
console.log(result);
|
||||
document.getElementById('id_ref').value = result.text;
|
||||
codeReader.reset();
|
||||
document.getElementById('scanner-obj').style.display = 'none';
|
||||
}
|
||||
if (err && !(err instanceof ZXing.NotFoundException)) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
document.getElementById('scanner-obj').style.display = 'block';
|
||||
console.log(`Started continuous decode from camera with id ${selectedDeviceId}`);
|
||||
});
|
||||
|
||||
document.getElementById('resetButton').addEventListener('click', () => {
|
||||
codeReader.reset();
|
||||
document.getElementById('id_ref').value = '';
|
||||
document.getElementById('scanner-obj').style.display = 'none';
|
||||
console.log('Reset.');
|
||||
});
|
||||
|
||||
}).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
|
|
|||
35
django/templates/_modal_connect_api_key.html
Normal file
35
django/templates/_modal_connect_api_key.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<div class="modal" tabindex="-1" id="connectApiKeyLabelbaseModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">API Connect</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
API Key: <strong><tt>{{ api_token }}</tt></strong>
|
||||
<br>
|
||||
Labelbase ID: <strong><tt>{{labelbase.id }}</tt></strong>
|
||||
<br>
|
||||
Base Endpoint: <strong><tt>https://labelbase.space/api/v0/</tt></strong><br>
|
||||
<small>NOTE: Replace "https://labelbase.space" with your own host. </small>
|
||||
</p>
|
||||
<center>
|
||||
<div style="padding:1.5em;" id="qrcode"></div>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
API keys work like passwords. Keep them secret!<br>
|
||||
Whoever knows the key can access your labelbases.<br>
|
||||
</div>
|
||||
</center>
|
||||
<p>
|
||||
Our API reference can be found here: <br>
|
||||
<a href="https://labelbase.space/api-reference/">https://labelbase.space/api-reference/</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
26
django/templates/_modal_delete_labelbase.html
Normal file
26
django/templates/_modal_delete_labelbase.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<div class="modal" tabindex="-1" id="deleteLabelbaseModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete labelbase and labels?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Warning:</strong>
|
||||
<br>
|
||||
<br>
|
||||
This action will permanently delete the labelbase and its labels and cannot be undone.
|
||||
<br><br>
|
||||
Are you sure you want to proceed?
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{% url 'del_labelbase' labelbase.pk %}">{% csrf_token %}
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">CANCEL</button>
|
||||
<button type="submit" class="btn btn-danger" data-bs-dismiss="modal">DELETE</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
24
django/templates/_modal_edit_labelbase.html
Normal file
24
django/templates/_modal_edit_labelbase.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{% load i18n %}
|
||||
{% load labelbase_tags %}
|
||||
{% load bootstrap %}
|
||||
<div class="modal" tabindex="-1" id="editLabelbaseModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<form action="{% url 'edit_labelbase' labelbase.id %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit labelbase</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% labelbaseform_edit labelbase as edit_form %}
|
||||
{{ edit_form|bootstrap }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -18,9 +18,19 @@
|
|||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
||||
|
||||
<div class="alert alert-warning" style="display:none;" id="samourai-warning" role="alert">
|
||||
<strong>Warning:</strong>
|
||||
<br>
|
||||
<br>
|
||||
Importing a samourai.txt backup file will expose the seed and should only be done on your personal instance of Labelbase that you control!
|
||||
</div>
|
||||
|
||||
{% genericlabeluploadform labelbase.id as impform %}
|
||||
{{ impform|bootstrap }}
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
|
|
@ -31,11 +41,34 @@
|
|||
</div>
|
||||
|
||||
{% addtoblock "js" %}
|
||||
|
||||
$(document).ready(function () {
|
||||
var form = $('#importLabelbaseModal form');
|
||||
form.on('submit', function () {
|
||||
// Disable the OK button on form submission
|
||||
$('#importLabelbaseModal button[type="submit"]').prop('disabled', true);
|
||||
var form = $('#importLabelbaseModal form');
|
||||
var importTypeField = $('#id_import_type');
|
||||
var passphraseFieldGroup = $('#id_passphrase').closest('.form-group');
|
||||
|
||||
function togglePassphraseField() {
|
||||
if (importTypeField.val() === 'samourai') {
|
||||
passphraseFieldGroup.show();
|
||||
$('#id_passphrase').prop('disabled', false);
|
||||
$('#samourai-warning').show();
|
||||
} else {
|
||||
passphraseFieldGroup.hide();
|
||||
$('#id_passphrase').prop('disabled', true);
|
||||
$('#id_passphrase').val('')
|
||||
$('#samourai-warning').hide();
|
||||
}
|
||||
}
|
||||
|
||||
importTypeField.on('change', togglePassphraseField);
|
||||
|
||||
// Initial call to set the correct state on page load
|
||||
togglePassphraseField();
|
||||
|
||||
form.on('submit', function () {
|
||||
// Disable the OK button on form submission
|
||||
$('#importLabelbaseModal button[type="submit"]').prop('disabled', true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
{% endaddtoblock %}
|
||||
|
|
|
|||
51
django/templates/cloud.html
Normal file
51
django/templates/cloud.html
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
{% extends "_base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}Interoperability{% endblock %}
|
||||
{% block nav_home %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="p-3 pb-md-4 mx-auto text-center">
|
||||
<h1 style="padding-top: 1.6em; padding-bottom: 0.9em; text-transform: uppercase;font-weight:800;">L<span style="height:1.1em; width:1em" data-feather="tag" class="align-text-bottom"></span>belbase</h1>
|
||||
<h2 class="display-6 fw-normal" style="padding-bottom: 0.9em;">All your labels in one place.</h2>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<main class="lb-header mx-auto text-center">
|
||||
<!--
|
||||
"Use your labels here, use your labels there – Dave doesn't care."
|
||||
|
||||
"Use this wallet today, use that wallet tomorrow – Dave doesn't care."
|
||||
-->
|
||||
<!-- Centered "Getting Started" CTA Block -->
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="mb-8">
|
||||
<img src="/static/Cloud.png"
|
||||
class="card-img-top"
|
||||
alt="..."
|
||||
style="padding: 0.25em;">
|
||||
<div class="card-body">
|
||||
<p style="font-weight: 700;">Cloud</p>
|
||||
|
||||
<p><i>"Use your labels here, use your labels there – Dave doesn't care."</i></p>
|
||||
<p>
|
||||
Be like Dave with Labelbase, your ultimate cloud-based platform for managing, merging, and synchronizing wallet labels across all your devices and wallet applications.
|
||||
|
||||
Labelbase seamlessly synchronizes your labels across various wallets and systems, ensuring you have consistent and organized data wherever you go. Simplify your financial management and stay in sync with Labelbase.
|
||||
</p>
|
||||
|
||||
|
||||
<p>
|
||||
<br><br>
|
||||
<a href="{{ reg_url }}" type="button" class="w-100 btn btn-lg btn-primary">Get started</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
{% endblock %}
|
||||
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 %}
|
||||
|
|
@ -7,22 +7,106 @@
|
|||
|
||||
{% block content %}
|
||||
|
||||
<div class=" p-3 pb-md-4 mx-auto text-left">
|
||||
<h2 class="display-8 fw-normal">Support Labelbase: Make a difference with your donation</h2>
|
||||
<br>
|
||||
<p class="fs-5 text-muted">Every contribution helps us build a better Bitcoin labeling experience.</p>
|
||||
<p class="fs-5 text-muted">
|
||||
Choose the amount you'd like to donate to Labelbase.
|
||||
</p> <p class="fs-5 text-muted">
|
||||
|
||||
Your generosity ensures that our project continues to thrive and evolve, directly supporting our mission to make Bitcoin transactions more organized and transparent.
|
||||
</p> <p class="fs-5 text-muted">
|
||||
Thank you for your support!
|
||||
|
||||
<div class="p-3 pb-md-4 mx-auto text-center" >
|
||||
<h2 class="display-8 fw-bold text-muted" style="padding-top: 2rem;padding-bottom: 0.9em;">Support Labelbase: Keep it Going</h2>
|
||||
|
||||
<p class="lb-header mx-auto text-center fs-5 text-muted">
|
||||
Every contribution helps us continue building a better Bitcoin labeling experience.
|
||||
</p>
|
||||
<p class="lb-header mx-auto text-center fs-5 text-muted">
|
||||
Choose the amount you'd like to donate to Labelbase.
|
||||
</p>
|
||||
<p class="lb-header mx-auto text-center fs-5 text-muted">
|
||||
As a free and open-source software, your generosity is crucial to our mission. Your support ensures our project keeps thriving and evolving, directly enhancing Bitcoin label management.
|
||||
</p>
|
||||
|
||||
|
||||
<style> .btcpay-form { display: inline-flex; align-items: center; justify-content: center; } .btcpay-form--inline { flex-direction: row; } .btcpay-form--block { flex-direction: column; } .btcpay-form--inline .submit { margin-left: 15px; } .btcpay-form--block select { margin-bottom: 10px; } .btcpay-form .btcpay-custom-container{ text-align: center; }.btcpay-custom { display: flex; align-items: center; justify-content: center; } .btcpay-form .plus-minus { cursor:pointer; font-size:25px; line-height: 25px; background: #DFE0E1; height: 30px; width: 45px; border:none; border-radius: 60px; margin: auto 5px; display: inline-flex; justify-content: center; } .btcpay-form select { -moz-appearance: none; -webkit-appearance: none; appearance: none; color: currentColor; background: transparent; border:1px solid transparent; display: block; padding: 1px; margin-left: auto; margin-right: auto; font-size: 11px; cursor: pointer; } .btcpay-form select:hover { border-color: #ccc; } .btcpay-form option { color: #000; background: rgba(0,0,0,.1); } .btcpay-input-price { -moz-appearance: textfield; border: none; box-shadow: none; text-align: center; font-size: 25px; margin: auto; border-radius: 5px; line-height: 35px; background: #fff; }.btcpay-input-price::-webkit-outer-spin-button, .btcpay-input-price::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } </style>
|
||||
<style> input[type=range].btcpay-input-range { -webkit-appearance:none; width:100%; background: transparent; } input[type=range].btcpay-input-range:focus { outline:0; } input[type=range].btcpay-input-range::-webkit-slider-runnable-track { width:100%; height:3.1px; cursor:pointer; box-shadow:0 0 1.7px #020,0 0 0 #003c00; background:#f3f3f3; border-radius:1px; border:0; } input[type=range].btcpay-input-range::-webkit-slider-thumb { box-shadow:none; border:2.5px solid #cedc21; height:22px; width:22px; border-radius:50%; background:#0f3723; cursor:pointer; -webkit-appearance:none; margin-top:-9.45px } input[type=range].btcpay-input-range:focus::-webkit-slider-runnable-track { background:#fff; } input[type=range].btcpay-input-range::-moz-range-track { width:100%; height:3.1px; cursor:pointer; box-shadow:0 0 1.7px #020,0 0 0 #003c00; background:#f3f3f3; border-radius:1px; border:0; } input[type=range].btcpay-input-range::-moz-range-thumb { box-shadow:none; border:2.5px solid #cedc21; height:22px; width:22px; border-radius:50%; background:#0f3723; cursor:pointer; } input[type=range].btcpay-input-range::-ms-track { width:100%; height:3.1px; cursor:pointer; background:0 0; border-color:transparent; color:transparent; } input[type=range].btcpay-input-range::-ms-fill-lower { background:#e6e6e6; border:0; border-radius:2px; box-shadow:0 0 1.7px #020,0 0 0 #003c00; } input[type=range].btcpay-input-range::-ms-fill-upper { background:#f3f3f3; border:0; border-radius:2px; box-shadow:0 0 1.7px #020,0 0 0 #003c00; } input[type=range].btcpay-input-range::-ms-thumb { box-shadow:none; border:2.5px solid #cedc21; height:22px; width:22px; border-radius:50%; background:#0f3723; cursor:pointer; height:3.1px; } input[type=range].btcpay-input-range:focus::-ms-fill-lower { background:#f3f3f3; } input[type=range].btcpay-input-range:focus::-ms-fill-upper { background:#fff; } </style>
|
||||
<form method="POST" action="https://pay.seedor.io/api/v1/invoices" class="btcpay-form btcpay-form--block">
|
||||
<input type="hidden" name="storeId" value="6BNs4QPhiLFM9yh7uByokgYf1okjCNq6dwAMaVWxgKZN" />
|
||||
<input type="hidden" name="checkoutDesc" value="Thank you for supporting Labelbase!" />
|
||||
<input type="hidden" name="browserRedirect" value="https://labelbase.space/thanks" />
|
||||
<!--input type="hidden" name="notifyEmail" value="xavier@labelbase.space" /-->
|
||||
<input type="hidden" name="notifyEmail" value="xavierfiechter@gmail.com" /-->
|
||||
|
||||
<div class="btcpay-custom-container ">
|
||||
<input class="btcpay-input-price" type="number" name="price" min="1" max="1000" step="1" value="50" data-price="50" style="width:209px;" />
|
||||
<select name="currency">
|
||||
<option value="USD" selected>USD</option>
|
||||
<option value="CHF">CHF</option>
|
||||
<option value="GBP">GBP</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="BTC">BTC</option>
|
||||
</select>
|
||||
<input type="range" class="btcpay-input-range" min="1" max="1000" step="1" value="50" style="width:209px;margin-bottom:15px;" />
|
||||
</div>
|
||||
<input type="hidden" name="defaultPaymentMethod" value="BTC_LightningLike" />
|
||||
<button type="submit" class="submit" name="submit" style="min-width:209px;min-height:57px;border-radius:4px;border-style:none;background-color:#0f3b21;cursor:pointer;" title="Pay with BTCPay Server, a Self-Hosted Bitcoin Payment Processor"><span style="color:#fff">Donate with</span>
|
||||
<img src="https://pay.seedor.io/img/paybutton/logo.svg" style="height:57px;display:inline-block;padding:5% 0 5% 5px;vertical-align:middle;">
|
||||
</button></form>
|
||||
<script>
|
||||
function handleSliderChange(event) {
|
||||
event.preventDefault();
|
||||
const root = event.target.closest('.btcpay-form');
|
||||
const el = root.querySelector('.btcpay-input-price');
|
||||
const price = parseInt(el.value);
|
||||
const min = parseInt(event.target.getAttribute('min')) || 1;
|
||||
const max = parseInt(event.target.getAttribute('max'));
|
||||
if (price < min) {
|
||||
el.value = min;
|
||||
} else if (price > max) {
|
||||
el.value = max;
|
||||
}
|
||||
root.querySelector('.btcpay-input-range').value = el.value;
|
||||
}
|
||||
function handleSliderInput(event) {
|
||||
event.target.closest('.btcpay-form').querySelector('.btcpay-input-price').value = event.target.value;
|
||||
}
|
||||
document.querySelectorAll(".btcpay-form .btcpay-input-range").forEach(function(el) {
|
||||
if (!el.dataset.initialized) {
|
||||
el.addEventListener('input', handleSliderInput);
|
||||
el.dataset.initialized = true;
|
||||
}
|
||||
});
|
||||
document.querySelectorAll(".btcpay-form .btcpay-input-price").forEach(function(el) {
|
||||
if (!el.dataset.initialized) {
|
||||
el.addEventListener('change', handleSliderChange);
|
||||
el.dataset.initialized = true;
|
||||
}
|
||||
});
|
||||
|
||||
function handlePriceInput(event) {
|
||||
event.preventDefault();
|
||||
const root = event.target.closest('.btcpay-form');
|
||||
const price = parseInt(event.target.dataset.price);
|
||||
if (isNaN(event.target.value)) root.querySelector('.btcpay-input-price').value = price;
|
||||
const min = parseInt(event.target.getAttribute('min')) || 1;
|
||||
const max = parseInt(event.target.getAttribute('max'));
|
||||
if (event.target.value < min) {
|
||||
event.target.value = min;
|
||||
} else if (event.target.value > max) {
|
||||
event.target.value = max;
|
||||
}
|
||||
}
|
||||
document.querySelectorAll(".btcpay-form .btcpay-input-price").forEach(function(el) {
|
||||
if (!el.dataset.initialized) {
|
||||
el.addEventListener('input', handlePriceInput);
|
||||
el.dataset.initialized = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<p class="lb-header mx-auto text-center fs-5 text-muted" style="padding-bottom:2rem;"><br>
|
||||
Thank you for keeping Labelbase going.
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
|
||||
<div style="display: inline-block; border-top: 1px solid black; ">
|
||||
<small>
|
||||
<a href="https://www.seedor.io/en/pages/about-us">Seedor</a> powers our <a href="https://btcpayserver.org/">BTCPay Server</a> instance, providing secure and private donations.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
<p class="fs-6 text-muted">
|
||||
<strong>Mainnet: </strong>
|
||||
<ul>
|
||||
<li>fulcrum.sethforprivacy.com / s50002</li>
|
||||
<li>electrum.emzy.de / s50002</li>
|
||||
<li>electrum.blockstream.info / s50002</li>
|
||||
</ul>
|
||||
|
|
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ code {
|
|||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="fs-6">
|
||||
{{ article.content | markdown | safe }}
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@
|
|||
{#% block nav_home %}active{% endblock %#}
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
||||
<div class="fs-6">
|
||||
{% breadcrumbs_category category as current_breadcrumbs %}
|
||||
<nav style="padding-top: 0.76rem; --bs-breadcrumb-divider: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8'%3E%3Cpath d='M2.5 0L1 1.5 3.5 4 1 6.5 2.5 8l4-4-4-4z' fill='%236c757d'/%3E%3C/svg%3E");" aria-label="breadcrumb">
|
||||
|
||||
|
|
@ -31,5 +30,5 @@
|
|||
<li class="list-group-item"><a href="{% url 'article_detail' article.slug %}">{{ article.title }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
{% load static %}
|
||||
{% load sekizai_tags %}
|
||||
{% block title %}Knowledge Base Categories | Labelbase{% endblock %}
|
||||
{#% block nav_home %}active{% endblock %#}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
@ -64,10 +33,13 @@
|
|||
<li class="nav-item">
|
||||
{% switch object.ref|slice:":4" %}
|
||||
{% case "xpub" %}
|
||||
{% case "tpub" %}
|
||||
<a class="nav-link {% if action == "derive-addresses" %}active{% endif %}" href="{% url 'edit_label' object.id %}derive-addresses/?derivation=m/44&address_count={{ address_count }}&offset={{ offset }}">Derive Addresses</a>
|
||||
{% case "ypub" %}
|
||||
{% case "upub" %}
|
||||
<a class="nav-link {% if action == "derive-addresses" %}active{% endif %}" href="{% url 'edit_label' object.id %}derive-addresses/?derivation=m/49&address_count={{ address_count }}&offset={{ offset }}">Derive Addresses</a>
|
||||
{% case "zpub" %}
|
||||
{% case "vpub" %}
|
||||
<a class="nav-link {% if action == "derive-addresses" %}active{% endif %}" href="{% url 'edit_label' object.id %}derive-addresses/?derivation=m/84&address_count={{ address_count }}&offset={{ offset }}">Derive Addresses</a>
|
||||
{% else %}
|
||||
<a class="nav-link {% if action == "derive-addresses" %}active{% endif %}" href="{% url 'edit_label' object.id %}derive-addresses/">Derive Addresses</a>
|
||||
|
|
@ -117,83 +89,50 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
|
||||
|
||||
|
||||
<!--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.
|
||||
{% is_label_id_in_queue object.id as is_in_queue %}
|
||||
{% if is_in_queue %}
|
||||
<div class="alert bd-callout bd-callout-info">
|
||||
<strong>Output in queue!</strong> This output is currently in the processing queue. It will be checked shortly.
|
||||
</div>
|
||||
{% else %}
|
||||
{% if object.type == "output" %}
|
||||
{% if output.get_spent_status == "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>
|
||||
{% elif output.get_spent_status == "unspent" %}
|
||||
<div class="bd-callout bd-callout-good">
|
||||
<strong>Output unspent!</strong> Blockchain records indicate that this output has not been spent yet.
|
||||
</div>
|
||||
{% elif output.get_spent_status == "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>
|
||||
{% else %}
|
||||
<div class="bd-callout bd-callout-warning">
|
||||
<strong>Unknown status:</strong> The status of this output could not be determined.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% 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">
|
||||
|
|
@ -216,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,36 +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>
|
||||
<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 }}
|
||||
{% if output.next_input_attributes %}
|
||||
Fee estimation will be made based on:
|
||||
|
||||
{{ output.next_input_attributes }}
|
||||
|
||||
{% if output.next_input_attributes.input_n %}
|
||||
* Assuming {{ output.next_input_attributes.input_m }}-of-{{ output.next_input_attributes.input_n }} multisig
|
||||
{% else %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
</pre>
|
||||
<!-- 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 %}
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% 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 %}
|
||||
|
|
|
|||
|
|
@ -9,24 +9,45 @@
|
|||
|
||||
{% include "_labelbase_header_info_menu.html" %}
|
||||
|
||||
|
||||
{% if labelbase %}
|
||||
{% if request.GET.tag %}
|
||||
Hashtag filter is active:
|
||||
<span class="badge badge-hashtag badge-hashtag-nohover">
|
||||
<tt style="pointer-events: none;">{{ request.GET.tag }}</tt>
|
||||
<button onclick="window.location='{% url 'labelbase' labelbase.id %}'";
|
||||
type="button"
|
||||
class="btn-close"
|
||||
style="padding-left: 0.4rem; margin-right: -0.1rem; font-size: .6rem; font-weight: bolder !important;"
|
||||
></button>
|
||||
<tt style="pointer-events: none;">{{ request.GET.tag }}</tt>
|
||||
<button onclick="window.location='{% url 'labelbase' labelbase.id %}'";
|
||||
type="button"
|
||||
class="btn-close"
|
||||
style="padding-left: 0.4rem; margin-right: -0.1rem; font-size: .6rem; font-weight: bolder !important;"></button>
|
||||
</span>
|
||||
{% else %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="table-responsive" style="padding-top:1em" >
|
||||
<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>
|
||||
|
|
@ -36,190 +57,333 @@
|
|||
<th scope="col">label</th>
|
||||
<th scope="col">origin</th>
|
||||
<th scope="col">spendable</th>
|
||||
<th scope="col">health</th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="datatable-info-regular"></div>
|
||||
{% else %}
|
||||
<p>There are no labels in this labelbase.</p>
|
||||
<p>You can create new labels by using the 'New Label' button on the top right, or <a href="#" data-bs-toggle="modal" data-bs-target="#importLabelbaseModal">import your existing labels</a>.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if labelbase %}
|
||||
<div class="d-md-none" style="padding-top:0.5em;">
|
||||
<div id="datatable-info-mobile"></div>
|
||||
<div class="input-group mb-3" style="padding-top:0.5em;">
|
||||
<input type="text" id="mobileSearch" class="form-control" placeholder="Search">
|
||||
</div>
|
||||
<div id="mobileLabels" class="row">
|
||||
<!-- Cards will be injected here -->
|
||||
</div>
|
||||
<div id="mobilePagination" class="mt-3">
|
||||
<!-- DataTable pagination will be injected here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "_modal_add_label.html" %}
|
||||
{% include "_modal_edit_labelbase.html" %}
|
||||
{% include "_modal_connect_api_key.html" %}
|
||||
{% include "_modal_delete_labelbase.html" %}
|
||||
|
||||
<!-- modal -->
|
||||
<div class="modal" tabindex="-1" id="editLabelbaseModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<form action="{% url 'edit_labelbase' labelbase.id %}" method="post">
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Edit labelbase</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% labelbaseform_edit labelbase as edit_form %}
|
||||
{{ edit_form|bootstrap }}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="submit" class="btn btn-primary">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
@media (max-width: 767.98px) {
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pagination .page-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
<div class="modal" tabindex="-1" id="connectApiKeyLabelbaseModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">API Connect</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
API Key: <strong><tt>{{ api_token }}</tt></strong>
|
||||
<br>
|
||||
Labelbase ID: <strong><tt>{{labelbase.id }}</tt></strong>
|
||||
<br>
|
||||
Base Endpoint: <strong><tt>https://labelbase.space/api/</tt></strong>
|
||||
</p>
|
||||
<center>
|
||||
<div style="padding:1.5em;" id="qrcode"></div>
|
||||
.pagination .page-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
API keys work like passwords. Keep them secret!<br>
|
||||
Whoever knows the key can access your labelbases.<br>
|
||||
</div>
|
||||
</center>
|
||||
<p>
|
||||
Our API reference can be found here: <br>
|
||||
<a href="https://labelbase.space/api-reference/">https://labelbase.space/api-reference/</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
.pagination .page-item:first-child .page-link,
|
||||
.pagination .page-item:last-child .page-link {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card {
|
||||
/* margin-bottom: 1rem;*/
|
||||
|
||||
<div class="modal" tabindex="-1" id="deleteLabelbaseModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete labelbase and labels?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Warning:</strong>
|
||||
<br>
|
||||
<br>
|
||||
This action will permanently delete the labelbase and its labels and cannot be undone.
|
||||
<br><br>
|
||||
Are you sure you want to proceed?
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{% url 'del_labelbase' labelbase.pk %}">{% csrf_token %}
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">CANCEL</button>
|
||||
<button type="submit" class="btn btn-danger" data-bs-dismiss="modal">DELETE</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
.card .card-body {
|
||||
padding: 1rem;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
<script>
|
||||
.card-text {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-text strong {
|
||||
display: inline-block;
|
||||
min-width: 80px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.td-key {
|
||||
width: 6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
{% addtoblock "js" %}
|
||||
$(document).ready(function () {
|
||||
window.currentTypeFilter = 'all';
|
||||
const dt_table = $('#bip329labels').DataTable({
|
||||
order: [[0, "asc"]],
|
||||
columns: [
|
||||
{ data: 'id', orderable: true, searchable: true },
|
||||
{ data: 'type', orderable: true, searchable: true },
|
||||
{ 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: 'get_fee_health_status_display', orderable: false, searchable: false}
|
||||
|
||||
const dt_table = $('#bip329labels').dataTable({
|
||||
order: [[0, "asc"]],
|
||||
columns: [
|
||||
{
|
||||
data: 'id',
|
||||
orderable: true,
|
||||
searchable: true
|
||||
],
|
||||
|
||||
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 }}&type={{ request.GET.type|default:'all' }}",
|
||||
type: 'GET',
|
||||
dataSrc: 'data'
|
||||
},
|
||||
{
|
||||
data: 'type',
|
||||
orderable: true,
|
||||
searchable: true
|
||||
},
|
||||
{
|
||||
data: 'ref',
|
||||
orderable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
data: 'label',
|
||||
orderable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
data: 'origin',
|
||||
orderable: true,
|
||||
searchable: true,
|
||||
},
|
||||
{
|
||||
data: 'spendable',
|
||||
orderable: true,
|
||||
searchable: true,
|
||||
drawCallback: function(settings) {
|
||||
let isMobile = $(window).width() < 768;
|
||||
if (isMobile) {
|
||||
renderMobileView(settings.json.data, settings._iDisplayStart, settings._iDisplayLength, settings._iRecordsDisplay, settings.fnRecordsTotal());
|
||||
}
|
||||
}
|
||||
],
|
||||
searching: true,
|
||||
processing: false,
|
||||
serverSide: true,
|
||||
stateSave: true,
|
||||
responsive: true,
|
||||
ajax: {
|
||||
url: "{% url 'labelbase_label_data' labelbase.pk %}?tag={{ request.GET.tag }}",
|
||||
type: 'GET'
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 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) {
|
||||
originText = `<p class="card-text"><strong>Origin:</strong> ${data.origin}</p>`;
|
||||
}
|
||||
let spendableText = '';
|
||||
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>`;
|
||||
}
|
||||
|
||||
|
||||
var qrcode = new QRCode("qrcode", {
|
||||
text: JSON.stringify({
|
||||
api_key: '{{ api_token }}',
|
||||
api_base: 'https://labelbase.space/api/',
|
||||
labelbase_id: {{labelbase.id }},
|
||||
name: '{{labelbase.name }}',
|
||||
fingerprint: '{{ labelbase.fingerprint }}',
|
||||
return `
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body position-relative">
|
||||
<strong>
|
||||
<span class="card-title" style="max-width: 88%; font-size: 1.2em; display: inline-block;">
|
||||
${data.label}
|
||||
</span>
|
||||
</strong>
|
||||
<button type="button" class="btn btn-link btn-sm position-absolute top-0 end-0 m-2">
|
||||
#${data.id}
|
||||
</button>
|
||||
<table class="table table-borderless mt-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="td-key"><strong>Type:</strong></td>
|
||||
<td>${data.type}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Ref:</strong></td>
|
||||
<td>${data.ref}</td>
|
||||
</tr>
|
||||
${originText ? `<tr><td colspan="2">${originText}</td></tr>` : ''}
|
||||
${spendableText ? `${spendableText}` : ''}
|
||||
${healthText}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function renderMobileView(data, start, length, totalRecords, totalRecordsAll) {
|
||||
$('#mobileLabels').empty();
|
||||
data.forEach(function(item) {
|
||||
$('#mobileLabels').append(createCard(item));
|
||||
});
|
||||
|
||||
renderMobilePagination(start, length, totalRecords);
|
||||
}
|
||||
|
||||
function renderMobilePagination(start, length, totalRecords) {
|
||||
const totalPages = Math.ceil(totalRecords / length);
|
||||
const currentPage = Math.ceil(start / length) + 1;
|
||||
|
||||
let paginationHtml = '<nav><ul class="pagination">';
|
||||
if (currentPage > 1) {
|
||||
paginationHtml += `<li class="page-item"><a class="page-link" href="#">Previous</a></li>`;
|
||||
}
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
paginationHtml += `<li class="page-item ${i === currentPage ? 'active' : ''}"><a class="page-link" href="#">${i}</a></li>`;
|
||||
}
|
||||
if (currentPage < totalPages) {
|
||||
paginationHtml += `<li class="page-item"><a class="page-link" href="#">Next</a></li>`;
|
||||
}
|
||||
paginationHtml += '</ul></nav>';
|
||||
|
||||
$('#mobilePagination').html(paginationHtml);
|
||||
|
||||
$('.page-link').click(function (e) {
|
||||
e.preventDefault();
|
||||
let page = $(this).text();
|
||||
if (page === 'Previous') {
|
||||
page = currentPage - 1;
|
||||
} else if (page === 'Next') {
|
||||
page = currentPage + 1;
|
||||
} else {
|
||||
page = parseInt(page);
|
||||
}
|
||||
const newStart = (page - 1) * length;
|
||||
dt_table.page(page - 1).draw(false);
|
||||
});
|
||||
}
|
||||
|
||||
function updateInfo(start, end, totalFiltered, total, isMobile) {
|
||||
let info = `Showing ${start} to ${end} of ${totalFiltered} entries`;
|
||||
if (totalFiltered !== total) {
|
||||
info += ` (filtered from ${total} total entries)`;
|
||||
}
|
||||
if (isMobile) {
|
||||
$('#datatable-info-mobile').html(info);
|
||||
$('#datatable-info-regular').empty(); // Clear regular info
|
||||
} else {
|
||||
$('#datatable-info-regular').html(info);
|
||||
$('#datatable-info-mobile').empty(); // Clear mobile info
|
||||
}
|
||||
}
|
||||
|
||||
$('#mobileSearch').on('keyup', function() {
|
||||
$('#bip329labels_filter input').val(this.value).trigger('keyup');
|
||||
update_showing();
|
||||
|
||||
});
|
||||
|
||||
$('#bip329labels_filter input').on('keyup', function() {
|
||||
$('#mobileSearch').val(this.value);
|
||||
});
|
||||
/*
|
||||
$(window).resize(function() {
|
||||
let isMobile = $(window).width() < 768;
|
||||
if (isMobile) {
|
||||
dt_table.ajax.reload(function(json) {
|
||||
renderMobileView(json.data, dt_table.page.info().start, dt_table.page.info().length, dt_table.page.info().recordsDisplay, dt_table.page.info().recordsTotal);
|
||||
updateInfo(dt_table.page.info().start + 1, Math.min(dt_table.page.info().start + dt_table.page.info().length, dt_table.page.info().recordsDisplay), dt_table.page.info().recordsDisplay, dt_table.page.info().recordsTotal, true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if ($(window).width() < 768) {
|
||||
dt_table.ajax.reload(function(json) {
|
||||
renderMobileView(json.data, dt_table.page.info().start, dt_table.page.info().length, dt_table.page.info().recordsDisplay, dt_table.page.info().recordsTotal);
|
||||
updateInfo(dt_table.page.info().start + 1, Math.min(dt_table.page.info().start + dt_table.page.info().length, dt_table.page.info().recordsDisplay), dt_table.page.info().recordsDisplay, dt_table.page.info().recordsTotal, true);
|
||||
});
|
||||
} */
|
||||
|
||||
|
||||
|
||||
function update_showing() {
|
||||
if ($(window).width() < 768) {
|
||||
dt_table.ajax.reload(function(json) {
|
||||
let start = dt_table.page.info().start;
|
||||
let length = json.data.length;
|
||||
let recordsDisplay = dt_table.page.info().recordsDisplay;
|
||||
let recordsTotal = dt_table.page.info().recordsTotal;
|
||||
|
||||
renderMobileView(json.data, start, length, recordsDisplay, recordsTotal);
|
||||
updateInfo(Math.min(recordsDisplay, start +1), Math.min(start + length, recordsDisplay), length, recordsTotal, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
// run once
|
||||
update_showing();
|
||||
|
||||
$(window).resize(function() {
|
||||
let isMobile = $(window).width() < 768;
|
||||
if (isMobile) {
|
||||
update_showing();
|
||||
/*dt_table.ajax.reload(function(json) {
|
||||
let start = dt_table.page.info().start;
|
||||
let length = json.data.length;
|
||||
let recordsDisplay = dt_table.page.info().recordsDisplay;
|
||||
let recordsTotal = dt_table.page.info().recordsTotal;
|
||||
|
||||
renderMobileView(json.data, start, length, recordsDisplay, recordsTotal);
|
||||
updateInfo(start + 1, Math.min(start + length, recordsDisplay), length, recordsTotal, true);
|
||||
});*/
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
var qrcode = new QRCode("qrcode", {
|
||||
text: JSON.stringify({
|
||||
api_key: '{{ api_token }}',
|
||||
api_base: 'https://labelbase.space/api/',
|
||||
labelbase_id: {{ labelbase.id }},
|
||||
name: '{{ labelbase.name }}',
|
||||
fingerprint: '{{ labelbase.fingerprint }}'
|
||||
}),
|
||||
|
||||
width: 200,
|
||||
height: 200,
|
||||
colorDark : "#000000",
|
||||
colorLight : "#ffffff",
|
||||
correctLevel : QRCode.CorrectLevel.H
|
||||
});
|
||||
|
||||
width: 200,
|
||||
height: 200,
|
||||
colorDark : "#000000",
|
||||
colorLight : "#ffffff",
|
||||
correctLevel : QRCode.CorrectLevel.H
|
||||
});
|
||||
});
|
||||
|
||||
function removeHashtag() {
|
||||
alert("Hashtag removed!"); // Example alert
|
||||
}
|
||||
|
||||
{% endaddtoblock %}
|
||||
</script>
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
{% endif %}
|
||||
<!-- end modal -->
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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,30 +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>';
|
||||
|
|
@ -204,9 +240,6 @@ function getPopoverContent(index) {
|
|||
content += '<div><strong>Performance:</strong> ' + window.records[index].performance + '%</div>';
|
||||
content += '<div><strong>Spent:</strong> ' + window.records[index].spent + '</div>';
|
||||
content += '<div><strong>Spendable:</strong> ' + window.records[index].spendable + '</div>';
|
||||
|
||||
|
||||
|
||||
content += '</div>';
|
||||
return content;
|
||||
}
|
||||
|
|
@ -243,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) {
|
||||
|
|
@ -284,35 +314,16 @@ document.getElementById("chart-area").onmouseout = function (evt) {
|
|||
|
||||
</div>
|
||||
|
||||
|
||||
{% else %}
|
||||
NO LABELBASE
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
{% addtoblock "js" %}
|
||||
$(document).ready(function () {
|
||||
|
||||
|
||||
|
||||
$('[data-toggle="popover"]').popover()
|
||||
|
||||
|
||||
|
||||
});
|
||||
{% endaddtoblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
@ -2,6 +2,6 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% block title %}{% trans "Logged Out" %}{% endblock %}</h1>
|
||||
<h2 style="padding-top: 1em;">{% block title %}{% trans "Logged Out" %}{% endblock %}</h2>
|
||||
<p>{% trans "See you around!" %}</p>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -49,13 +49,7 @@
|
|||
<p style="padding-top: 2.1em;">
|
||||
{% url 'registration' as reg_url %}
|
||||
<strong>First time here?</strong> Let's <a href="{{ reg_url }}">get started</a>.
|
||||
|
||||
</p>
|
||||
<p style="padding-top: .1em;">
|
||||
<strong>Having problems logging in?</strong> If your credentials are not functioning as expected, it is possible that your account is hosted our <a href="https://legacy.labelbase.space/">legacy system</a>.
|
||||
</p>
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% block 'backup_tokens' %}
|
||||
|
|
|
|||
6
django/threadlocals/__init__.py
Normal file
6
django/threadlocals/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# User: Troy Evans
|
||||
# Date: 1/24/13
|
||||
# Time: 8:06 PM
|
||||
#
|
||||
# Copyright 2012, Nutrislice Inc.
|
||||
VERSION = '0.10'
|
||||
35
django/threadlocals/middleware.py
Normal file
35
django/threadlocals/middleware.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
threadlocals Middleware, provides a better, faster way to get at request and user.
|
||||
|
||||
:Authors:
|
||||
- Ben Roberts (Nutrislice, Inc.)
|
||||
- Troy Evans (Nutrislice, Inc.)
|
||||
- Herbert Poul http://sct.sphene.net
|
||||
- Bruce Kroeze
|
||||
|
||||
Branched from [http://code.djangoproject.com/wiki/CookBookthreadlocalsAndUser CookBookThreadLocalsAndUser]
|
||||
as modified by [http://sct.sphene.net Sphene Community tools].
|
||||
|
||||
(see license.txt)
|
||||
"""
|
||||
|
||||
from .threadlocals import set_thread_variable, del_thread_variables
|
||||
try:
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
except ImportError:
|
||||
MiddlewareMixin = object
|
||||
|
||||
class ThreadLocalMiddleware(MiddlewareMixin):
|
||||
"""Middleware that puts the request object in thread local storage."""
|
||||
|
||||
def process_request(self, request):
|
||||
set_thread_variable('request', request)
|
||||
# set_current_user(request.user) # not going to store user in TL's for now, since we can get it from the request if we need it, and I read somewhere that accessing reqeust.user can potentially prevent view caching from functioning correctly
|
||||
|
||||
def process_response(self, request, response):
|
||||
del_thread_variables()
|
||||
return response
|
||||
|
||||
def process_exception(self, request, exception):
|
||||
del_thread_variables()
|
||||
88
django/threadlocals/threadlocals.py
Normal file
88
django/threadlocals/threadlocals.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
__init__ module for the threadlocals package
|
||||
|
||||
Derived from django-threaded-multihost (see license.txt)
|
||||
"""
|
||||
__docformat__ = "restructuredtext"
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger('threadlocals.middleware')
|
||||
|
||||
from threading import local
|
||||
|
||||
_threadlocals = local()
|
||||
_threadvariables = set()
|
||||
|
||||
|
||||
def set_thread_variable(key, val):
|
||||
_threadvariables.add(key)
|
||||
setattr(_threadlocals, key, val)
|
||||
|
||||
|
||||
def get_thread_variable(key, default=None):
|
||||
return getattr(_threadlocals, key, default)
|
||||
|
||||
|
||||
def del_thread_variable(key):
|
||||
if hasattr(_threadlocals, key):
|
||||
delattr(_threadlocals, key)
|
||||
|
||||
|
||||
def del_thread_variables():
|
||||
for key in _threadvariables:
|
||||
del_thread_variable(key)
|
||||
|
||||
|
||||
def get_current_request():
|
||||
return get_thread_variable('request', None)
|
||||
|
||||
|
||||
def get_current_session():
|
||||
req = get_current_request()
|
||||
if req is None:
|
||||
return None
|
||||
return req.session
|
||||
|
||||
|
||||
def get_current_user():
|
||||
user = get_thread_variable('user', None)
|
||||
if user is None:
|
||||
req = get_current_request()
|
||||
if req == None or not hasattr(req, 'user'):
|
||||
return None
|
||||
user = req.user
|
||||
return user
|
||||
|
||||
|
||||
def set_current_user(user):
|
||||
set_thread_variable('user', user)
|
||||
|
||||
|
||||
def set_request_variable(key, val, use_threadlocal_if_no_request=True):
|
||||
request = get_current_request()
|
||||
if not request:
|
||||
if not use_threadlocal_if_no_request:
|
||||
raise RuntimeError(
|
||||
"Unable to set request variable. No request available in threadlocals. Is ThreadLocalMiddleware installed?")
|
||||
set_thread_variable(key, val)
|
||||
else:
|
||||
try:
|
||||
var_store = getattr(request, '_variables')
|
||||
except AttributeError:
|
||||
setattr(request, '_variables', {})
|
||||
var_store = getattr(request, '_variables')
|
||||
var_store[key] = val
|
||||
|
||||
|
||||
def get_request_variable(key, default=None, use_threadlocal_if_no_request=True):
|
||||
request = get_current_request()
|
||||
if not request:
|
||||
if not use_threadlocal_if_no_request:
|
||||
raise RuntimeError(
|
||||
"Unable to get request variable. No threadlocal request available. Is ThreadLocalMiddleware installed?")
|
||||
else:
|
||||
return get_thread_variable(key, default)
|
||||
return request._variables.get(key, default) if hasattr(request, '_variables') else default
|
||||
|
||||
|
|
@ -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