mirror of
https://github.com/Labelbase/Labelbase.git
synced 2026-08-14 12:43:22 +02:00
Compare commits
No commits in common. "master" and "2.1.0" have entirely different histories.
112 changed files with 1042 additions and 6672 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,7 +5,6 @@ labelbase.log
|
|||
labelbase.log.*
|
||||
bgt.log
|
||||
db/
|
||||
backup_*
|
||||
*.pyc
|
||||
__pycache__
|
||||
django/importer/uploadeddata/*
|
||||
|
|
|
|||
|
|
@ -1,896 +0,0 @@
|
|||
# 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!**
|
||||
|
|
@ -1,357 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,312 +0,0 @@
|
|||
# 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`
|
||||
|
|
@ -7,19 +7,17 @@ docker-compose down # make sure Labelbase is terminated.
|
|||
if [[ -z "${MYSQL_ROOT_PASSWORD}" ]]; then
|
||||
echo "Error: MYSQL_ROOT_PASSWORD environment variable is not set"
|
||||
exit 1
|
||||
else
|
||||
export $MYSQL_ROOT_PASSWORD
|
||||
fi
|
||||
|
||||
if [[ -z "${MYSQL_PASSWORD}" ]]; then
|
||||
echo "Error: MYSQL_PASSWORD environment variable is not set"
|
||||
exit 1
|
||||
else
|
||||
export $MYSQL_PASSWORD
|
||||
fi
|
||||
|
||||
|
||||
export MYSQL_ROOT_PASSWORD
|
||||
export MYSQL_PASSWORD
|
||||
|
||||
|
||||
|
||||
# Check git next
|
||||
|
||||
LAST_GIT_COMMIT_FILE=".last_git_commit"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.11
|
||||
FROM python:3.9
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
|
|
@ -11,7 +11,7 @@ RUN apt-get update && \
|
|||
default-libmysqlclient-dev \
|
||||
build-essential \
|
||||
cron vim logrotate \
|
||||
libpcre2-dev \
|
||||
libpcre3-dev \
|
||||
default-mysql-client \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& pip install --upgrade pip \
|
||||
|
|
|
|||
BIN
django/attachments/django-attachments-1.11.zip
Normal file
BIN
django/attachments/django-attachments-1.11.zip
Normal file
Binary file not shown.
|
|
@ -15,12 +15,19 @@ import logging
|
|||
logger = logging.getLogger('labelbase')
|
||||
|
||||
def attachment_upload(instance, filename):
|
||||
pass # used for compatibility / migrations only.
|
||||
"""Stores the attachment in a "per module/appname/primary key" folder"""
|
||||
return "attachments/{app}_{model}/{pk}/{filename}".format(
|
||||
app=instance.content_object._meta.app_label,
|
||||
model=instance.content_object._meta.object_name.lower(),
|
||||
pk=instance.content_object.pk,
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
class AttachmentManager(models.Manager):
|
||||
def attachments_for_object(self, obj):
|
||||
object_type = ContentType.objects.get_for_model(obj)
|
||||
print ("x attachments_for_object id {} , {} {}".format( obj.pk, object_type, object_type.id ))
|
||||
return self.filter(content_type__pk=object_type.id, object_id=obj.pk)
|
||||
|
||||
|
||||
|
|
@ -28,7 +35,6 @@ class AttachmentManager(models.Manager):
|
|||
class Attachment(models.Model):
|
||||
objects = AttachmentManager()
|
||||
|
||||
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.CharField(db_index=True, max_length=64)
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
|
@ -39,7 +45,7 @@ class Attachment(models.Model):
|
|||
on_delete=models.CASCADE,
|
||||
)
|
||||
attachment_file = models.FileField(
|
||||
_("attachment"), upload_to=upload_to
|
||||
_("attachment"), upload_to=upload_to # attachment_upload
|
||||
)
|
||||
created = models.DateTimeField(_("created"), auto_now_add=True, db_index=True)
|
||||
modified = models.DateTimeField(_("modified"), auto_now=True, db_index=True)
|
||||
|
|
@ -77,7 +83,7 @@ class Attachment(models.Model):
|
|||
|
||||
if update_path:
|
||||
old_path = self.attachment_file.path
|
||||
self.attachment_file.name = upload_to
|
||||
self.attachment_file.name = upload_to # attachment_upload(self, self.filename)
|
||||
self.attachment_file.save()
|
||||
|
||||
os.makedirs(
|
||||
|
|
|
|||
|
|
@ -22,24 +22,38 @@ def attachment_form(context, obj, **kwargs):
|
|||
The user must own ``attachments.add_attachment permission`` to add
|
||||
attachments.
|
||||
"""
|
||||
|
||||
return {
|
||||
"form": AttachmentForm(),
|
||||
"form_url": add_url_for_obj(obj),
|
||||
"next": context.request.path,
|
||||
}
|
||||
if context["user"].has_perm("attachments.add_attachment"):
|
||||
return {
|
||||
"form": AttachmentForm(),
|
||||
"form_url": add_url_for_obj(obj),
|
||||
"next": context.request.path,
|
||||
}
|
||||
else:
|
||||
return {"form": None}
|
||||
|
||||
|
||||
@register.inclusion_tag("attachments/delete_link.html", takes_context=True)
|
||||
def attachment_delete_link(context, attachment, **kwargs):
|
||||
if context["user"] == attachment.creator:
|
||||
"""
|
||||
Renders a html link to the delete view of the given attachment. Returns
|
||||
no content if the request-user has no permission to delete attachments.
|
||||
|
||||
The user must own either the ``attachments.delete_attachment`` permission
|
||||
and is the creator of the attachment, that he can delete it or he has
|
||||
``attachments.delete_foreign_attachments`` which allows him to delete all
|
||||
attachments.
|
||||
"""
|
||||
if context["user"].has_perm("attachments.delete_foreign_attachments") or (
|
||||
context["user"] == attachment.creator
|
||||
and context["user"].has_perm("attachments.delete_attachment")
|
||||
):
|
||||
return {
|
||||
"next": context.request.path,
|
||||
"delete_url": reverse(
|
||||
"attachments:delete", kwargs={"attachment_pk": attachment.pk}
|
||||
),
|
||||
}
|
||||
|
||||
return {"delete_url": None}
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ def add_attachment(
|
|||
extra_context=None,
|
||||
):
|
||||
next_ = request.POST.get("next", "/")
|
||||
|
||||
if not request.user.has_perm("attachments.add_attachment"):
|
||||
return HttpResponseRedirect(next_)
|
||||
|
||||
model = apps.get_model(app_label, model_name)
|
||||
obj = get_object_or_404(model, pk=pk)
|
||||
obj = obj.get_label_attachment() # our label to attachment proxy
|
||||
|
|
@ -67,13 +71,17 @@ def add_attachment(
|
|||
"next": next_,
|
||||
}
|
||||
template_context.update(extra_context or {})
|
||||
|
||||
return render(request, template_name, template_context)
|
||||
|
||||
|
||||
@login_required
|
||||
def delete_attachment(request, attachment_pk):
|
||||
g = get_object_or_404(Attachment, pk=attachment_pk)
|
||||
if request.user == g.creator:
|
||||
if (
|
||||
request.user.has_perm("attachments.delete_attachment")
|
||||
and request.user == g.creator
|
||||
) or request.user.has_perm("attachments.delete_foreign_attachments"):
|
||||
remove_file_from_disk(g.attachment_file)
|
||||
g.delete()
|
||||
messages.success(request, gettext("Your attachment was deleted."))
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ 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)
|
||||
|
||||
|
|
@ -95,6 +96,8 @@ 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,8 +5,5 @@ register = template.Library()
|
|||
|
||||
@register.simple_tag
|
||||
def is_label_id_in_queue(label_id):
|
||||
try:
|
||||
return Task.objects.filter(task_name="finances.tasks.check_spent",
|
||||
task_params__contains=label_id).exists()
|
||||
except:
|
||||
return False
|
||||
return Task.objects.filter(task_name="finances.tasks.check_spent",
|
||||
task_params__contains=label_id).exists()
|
||||
|
|
|
|||
|
|
@ -2,16 +2,7 @@ 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',
|
||||
'next_enc_input_attrs',
|
||||
'last_error')
|
||||
list_display = ('type_ref_hash', 'value', 'confirmed_at_block_height', 'confirmed_at_block_time', 'get_spent_status', 'spent', 'network', 'user')
|
||||
list_filter = ('network', 'spent')
|
||||
search_fields = ('type_ref_hash',)
|
||||
ordering = ('-confirmed_at_block_time',)
|
||||
|
|
|
|||
|
|
@ -1,32 +1,36 @@
|
|||
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
|
||||
|
||||
import logging
|
||||
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(f"blocktime: {blocktime}")
|
||||
logger.debug("blocktime: {}".format(blocktime))
|
||||
except Exception as ex:
|
||||
blocktime = 0
|
||||
logger.error(f"Can't get blocktime: {ex}")
|
||||
logger.error("Can't get blocktime: {}".format(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(f"ERROR: {ex} {conn.last_error}")
|
||||
logger.error("ERROR: {} {}".format(ex, conn.last_error))
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -34,117 +38,198 @@ 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:
|
||||
logger.error(ex)
|
||||
print(ex)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def is_valid_output_ref(ref):
|
||||
return ":" in ref if ref else False
|
||||
|
||||
if not ref:
|
||||
return False
|
||||
if ":" in ref:
|
||||
return True
|
||||
return False
|
||||
|
||||
def checkup_label(label_id, loop):
|
||||
if not label_id or not loop:
|
||||
logger.error(f"Invalid input: label_id={label_id}, loop={loop}")
|
||||
return
|
||||
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)
|
||||
|
||||
try:
|
||||
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:
|
||||
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:
|
||||
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))
|
||||
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))
|
||||
conn = StratumClient()
|
||||
assert elem.type_ref_hash
|
||||
utxo = elem.ref
|
||||
|
||||
# Fetch transaction details
|
||||
tx_hash, tx_pos = elem.ref.split(":")
|
||||
utxo_resp = loop.run_until_complete(interact(conn, server_info, "blockchain.transaction.get", utxo))
|
||||
|
||||
blocktime = 0
|
||||
if utxo_resp:
|
||||
txid, index, address, value, blocktime, utxo_data = utxo_resp
|
||||
logger.debug(f"Transaction {txid} fetched with blocktime {blocktime}")
|
||||
print("utxo_resp {}".format(utxo_resp))
|
||||
txid, index, address, value, blocktime = utxo_resp
|
||||
if blocktime:
|
||||
output.confirmed_at_block_time = blocktime
|
||||
HistoricalPrice.get_or_create_from_api(None, timestamp=blocktime)
|
||||
|
||||
# Fetch all unspents for the address
|
||||
print("Found blocktime {} for label id {}.".format(blocktime, label_id))
|
||||
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
|
||||
conn.last_error = None # reset error if needed
|
||||
unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.scripthash.listunspent", address))
|
||||
|
||||
logger.debug(f"Unspents for address {address}: {unspents}")
|
||||
unspent_utxo = False
|
||||
utxo_value = 0
|
||||
utxo_height = 0
|
||||
print("unspents: {}".format(unspents))
|
||||
|
||||
utxo_found = False
|
||||
for unspent in unspents:
|
||||
if unspent.get('tx_hash') == txid and unspent.get('tx_pos') == int(index):
|
||||
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:
|
||||
output.spent = False
|
||||
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.")
|
||||
|
||||
else:
|
||||
output.spent = True
|
||||
output.last_error = {}
|
||||
elif conn.last_error:
|
||||
# Damn...
|
||||
output.last_error = conn.last_error
|
||||
else:
|
||||
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.last_error = {}
|
||||
output.save()
|
||||
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}")
|
||||
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!")
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
# 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,6 +1,5 @@
|
|||
import requests
|
||||
from django.db import models
|
||||
from django.contrib import messages
|
||||
from djmoney.models.fields import MoneyField
|
||||
from decimal import Decimal
|
||||
import datetime
|
||||
|
|
@ -8,13 +7,14 @@ 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')
|
||||
|
||||
|
||||
|
||||
class OutputStat(models.Model):
|
||||
"""
|
||||
These fields are unencrypted. Why?
|
||||
|
|
@ -43,20 +43,7 @@ class OutputStat(models.Model):
|
|||
confirmed_at_block_time = models.IntegerField(default=0)
|
||||
|
||||
last_error = JSONField(default={})
|
||||
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("{}")
|
||||
|
||||
next_input_attributes = JSONField(default={}) # will be used for fee estimation
|
||||
MAINNET = 'mainnet'
|
||||
TESTNET = 'testnet'
|
||||
|
||||
|
|
@ -77,12 +64,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"
|
||||
elif not self.spent:
|
||||
if 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"):
|
||||
"""
|
||||
|
|
@ -102,8 +89,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(self.user,
|
||||
timestamp=self.confirmed_at_block_time
|
||||
obj, created = HistoricalPrice.get_or_create_from_api(
|
||||
timestamp=self.confirmed_at_block_time
|
||||
)
|
||||
if obj is None:
|
||||
logger.error("No price info found for {}".format(self.confirmed_at_block_time))
|
||||
|
|
@ -126,8 +113,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(self.user,
|
||||
timestamp=timestamp
|
||||
obj_now, created = HistoricalPrice.get_or_create_from_api(
|
||||
timestamp=timestamp
|
||||
)
|
||||
|
||||
# Calculate the current price
|
||||
|
|
@ -158,7 +145,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(self.user,
|
||||
obj, created = HistoricalPrice.get_or_create_from_api(
|
||||
timestamp=self.confirmed_at_block_time)
|
||||
|
||||
performance = 0
|
||||
|
|
@ -206,11 +193,13 @@ class OutputStat(models.Model):
|
|||
network=network).last()
|
||||
|
||||
if cached_data:
|
||||
print("found cached data {} for type_ref_hash {}".format(cached_data, type_ref_hash))
|
||||
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)
|
||||
|
|
@ -222,9 +211,10 @@ 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={
|
||||
|
|
@ -276,31 +266,17 @@ class HistoricalPrice(models.Model):
|
|||
ordering = ['-timestamp']
|
||||
|
||||
@classmethod
|
||||
def get_or_create_from_api(cls, user=None, timestamp=-1):
|
||||
def get_or_create_from_api(cls, timestamp=-1):
|
||||
print("running get_or_create_from_api @ timestamp {}".format(timestamp))
|
||||
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
|
||||
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
|
||||
url = f"https://mempool.space/api/v1/historical-price?timestamp={timestamp}"
|
||||
response = requests.get(url)
|
||||
api_response = response.json()
|
||||
try:
|
||||
obj, created = cls.objects.get_or_create(timestamp=timestamp, defaults={
|
||||
'usd_price': Decimal(str(api_response['prices'][0]['USD'])),
|
||||
|
|
|
|||
|
|
@ -1,25 +1,20 @@
|
|||
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):
|
||||
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}"
|
||||
))
|
||||
|
||||
""" """
|
||||
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.")
|
||||
# Store nearest price information.
|
||||
HistoricalPrice.get_or_create_from_api(user, -1)
|
||||
HistoricalPrice.get_or_create_from_api(-1)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,223 +0,0 @@
|
|||
# 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()
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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,8 +8,3 @@ class UploadFileForm(forms.Form):
|
|||
choices=IMPORTER_CHOICES
|
||||
)
|
||||
file = forms.FileField()
|
||||
passphrase = forms.CharField(
|
||||
widget=forms.PasswordInput(),
|
||||
required=False,
|
||||
max_length=100
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,18 +2,15 @@ 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,5 +1,7 @@
|
|||
import csv
|
||||
import json
|
||||
import json
|
||||
from labelbase.models import Label
|
||||
from labelbase.models import Labelbase
|
||||
|
||||
|
||||
def validate_csv_format(csv_file_path):
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
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,10 +2,9 @@ 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
|
||||
|
||||
|
|
@ -13,135 +12,93 @@ EOLSTOP = [b"", "", None, "\n"]
|
|||
|
||||
|
||||
@background(schedule=1)
|
||||
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()
|
||||
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()
|
||||
|
||||
# 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(",")
|
||||
# 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
|
||||
data = {
|
||||
"type": "tx",
|
||||
"ref": sbuf[1],
|
||||
"label": " ".join(sbuf[3:]),
|
||||
"ref": txid,
|
||||
"label": "{} {}".format(label, "#W001_UTXO_NOT_FOUND"),
|
||||
}
|
||||
if data:
|
||||
data["labelbase"] = labelbase.id
|
||||
serializer = LabelSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
|
|
@ -151,20 +108,50 @@ def process_uploaded_data(uploaded_data_id, passphrase=None, loop=None):
|
|||
# messages.add_message(
|
||||
# request,
|
||||
# messages.ERROR,
|
||||
# 'Could not process line "{}".'.format(buf),
|
||||
# 'Could not process record "{}".'.format(item),
|
||||
# )
|
||||
pass
|
||||
except Exception as ex:
|
||||
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:
|
||||
# messages.add_message(
|
||||
# request,
|
||||
# messages.ERROR,
|
||||
# 'Could not process line "{}", {}.'.format(buf, ex),
|
||||
# 'Could not process line "{}".'.format(buf),
|
||||
# )
|
||||
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()
|
||||
except Exception as ex:
|
||||
logger.error(ex, exc_info=True)
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -10,4 +10,27 @@ 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, passphrase=form.cleaned_data.get("passphrase", None))
|
||||
process_uploaded_data(uploaded_data.id)
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.INFO,
|
||||
|
|
|
|||
|
|
@ -1,36 +1,6 @@
|
|||
from django.contrib import admin
|
||||
from .models import Labelbase
|
||||
from .models import Label
|
||||
from django.conf import settings
|
||||
|
||||
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)
|
||||
admin.site.register(Labelbase)
|
||||
admin.site.register(Label)
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ from labelbase.models import Labelbase, Label
|
|||
from labelbase.serializers import LabelbaseSerializer, LabelSerializer
|
||||
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger('labelbase')
|
||||
|
||||
class LabelbaseAPIView(APIView):
|
||||
"""
|
||||
Labelbase
|
||||
|
|
@ -93,7 +90,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()
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ class LabelForm(forms.ModelForm):
|
|||
for field_name in self.fields:
|
||||
self.fields[field_name].label = mark_safe(
|
||||
f'<label class="bip329-attr">{self.fields[field_name].label}</label>')
|
||||
|
||||
|
||||
|
||||
class ExportLabelsForm(forms.Form):
|
||||
""" """
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
# 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,7 +2,6 @@ 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
|
||||
|
||||
|
|
@ -113,13 +112,6 @@ class Labelbase(models.Model):
|
|||
def get_hashtags_url(self):
|
||||
return reverse('labelbase_hashtags', kwargs={'labelbase_id': self.id})
|
||||
|
||||
def get_xpub_url(self):
|
||||
for label in self.label_set.all():
|
||||
if label.type == "xpub": # and is_valid_xpub() ...
|
||||
# returns the first xpub, works for single signature only at the moment.
|
||||
return reverse('edit_label', kwargs={'pk': label.id})
|
||||
return None
|
||||
|
||||
|
||||
class Label(models.Model):
|
||||
"""
|
||||
|
|
@ -131,7 +123,7 @@ class Label(models.Model):
|
|||
TYPE_PUBKEY = "pubkey"
|
||||
TYPE_INPUT = "input"
|
||||
TYPE_OUTPUT = "output"
|
||||
TYPE_XPUB = "xpub"
|
||||
TYPE_XPUT = "xpub"
|
||||
|
||||
TYPE_CHOICES = [
|
||||
(TYPE_TX, "tx"),
|
||||
|
|
@ -139,7 +131,7 @@ class Label(models.Model):
|
|||
(TYPE_PUBKEY, "pubkey"),
|
||||
(TYPE_INPUT, "input"),
|
||||
(TYPE_OUTPUT, "output"),
|
||||
(TYPE_XPUB, "xpub"),
|
||||
(TYPE_XPUT, "xpub"),
|
||||
]
|
||||
|
||||
type = models.CharField(
|
||||
|
|
@ -181,77 +173,9 @@ 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)
|
||||
|
||||
# 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)"
|
||||
)
|
||||
)
|
||||
type_ref_hash = models.CharField(max_length=64, blank=True)
|
||||
|
||||
def get_extracted_fiat_value(self):
|
||||
return extract_fiat_value(self.label)
|
||||
|
|
@ -277,7 +201,7 @@ class Label(models.Model):
|
|||
def get_absolute_url(self):
|
||||
"""
|
||||
Is used by "edit label" functionality.
|
||||
This brings us back to the labelbase once the label was saved.
|
||||
This brings us back to the labelbase once the lable was saved.
|
||||
"""
|
||||
return self.labelbase.get_absolute_url()
|
||||
|
||||
|
|
@ -299,102 +223,3 @@ 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,9 +1,8 @@
|
|||
import json
|
||||
from rest_framework import serializers
|
||||
from labelbase.models import Labelbase, Label
|
||||
|
||||
|
||||
class LabelSerializer_v1(serializers.ModelSerializer):
|
||||
class LabelSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Label
|
||||
fields = [
|
||||
|
|
@ -20,169 +19,6 @@ class LabelSerializer_v1(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)
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB |
File diff suppressed because one or more lines are too long
13
django/labelbase/static/js/feather.min.js
vendored
13
django/labelbase/static/js/feather.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
django/labelbase/static/js/qrcode.min.js
vendored
1
django/labelbase/static/js/qrcode.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -8,7 +8,6 @@ from labelbase.forms import ExportLabelsForm
|
|||
register = template.Library()
|
||||
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def is_self_hosted():
|
||||
return settings.SELF_HOSTED
|
||||
|
|
|
|||
|
|
@ -13,22 +13,26 @@ def read_file_content(file_path):
|
|||
|
||||
|
||||
def generate_random_string(length):
|
||||
# Exclude curly braces '{}' from the pool of characters
|
||||
characters = string.ascii_letters + string.digits
|
||||
return ''.join(secrets.choice(characters) for _ in range(length))
|
||||
|
||||
|
||||
def generate_config_file(config_file_path="config.ini"):
|
||||
config = configparser.ConfigParser()
|
||||
# Generate random values where needed
|
||||
dj_secret_key = generate_random_string(50)
|
||||
#database_password = read_file_content("/run/secrets/mysql_password")
|
||||
database_password = os.getenv("MYSQL_PASSWORD")
|
||||
crypto_salt = 'labelbase_{}_'.format(generate_random_string(32))
|
||||
|
||||
# Set the values in the configuration file
|
||||
config['internal'] = {
|
||||
'secret_key': '{}'.format(dj_secret_key),
|
||||
'proj_name': 'labelbase',
|
||||
'crypto_salt': crypto_salt,
|
||||
'allowed_host': '*',
|
||||
'debug': False,
|
||||
'debug': True,
|
||||
'current_timestamp_seconds': int(time.time()),
|
||||
'sentry_dsn': 'https://3b833ae08ccc4ff68793e961fff4921c@o4504646963232768.ingest.sentry.io/4504646967361536',
|
||||
}
|
||||
|
|
@ -37,15 +41,16 @@ def generate_config_file(config_file_path="config.ini"):
|
|||
'name': 'labelbase',
|
||||
'user': 'ulabelbase',
|
||||
'password': database_password,
|
||||
'host': os.getenv("MYSQL_HOST", "127.0.0.1"),
|
||||
'port': os.getenv("MYSQL_PORT", "3306"),
|
||||
# 'host': '127.0.0.1'
|
||||
}
|
||||
|
||||
# Create the configuration file and write the values
|
||||
with open(config_file_path, 'w') as configfile:
|
||||
config.write(configfile)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Check if the config.ini file exists
|
||||
if not os.path.isfile(config_file_path):
|
||||
generate_config_file()
|
||||
print("Config file {} created.".format(config_file_path))
|
||||
|
|
|
|||
|
|
@ -33,13 +33,14 @@ except AssertionError:
|
|||
SECRET_KEY = proj_config.get("internal", "secret_key")
|
||||
CRYPTOGRAPHY_SALT = proj_config.get("internal", "crypto_salt")
|
||||
|
||||
DEBUG = proj_config.getboolean("internal", "debug")
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True # proj_config.getboolean("internal", "debug")
|
||||
|
||||
SELF_HOSTED = proj_config.getboolean("internal", "self_hosted", fallback=True)
|
||||
if DEBUG:
|
||||
ALLOWED_HOSTS = ["*"] # we don't know your host config, keep like that at the moment.
|
||||
else:
|
||||
ALLOWED_HOSTS = [proj_config.get("internal", "allowed_host")]
|
||||
#if DEBUG:
|
||||
ALLOWED_HOSTS = ["*"] # we don't know your host config, keep like that at the moment.
|
||||
#else:
|
||||
# ALLOWED_HOSTS = [proj_config.get("internal", "allowed_host")]
|
||||
|
||||
#SENTRY_DSN = "https://3b833ae08ccc4ff68793e961fff4921c@o4504646963232768.ingest.sentry.io/4504646967361536"
|
||||
|
||||
|
|
@ -54,7 +55,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.3")
|
||||
sentry_sdk.set_tag("version", "2.1.0")
|
||||
|
||||
|
||||
LOGGING = {
|
||||
|
|
@ -103,11 +104,8 @@ INSTALLED_APPS = [
|
|||
"django_otp",
|
||||
"django_otp.plugins.otp_static",
|
||||
"django_otp.plugins.otp_totp",
|
||||
|
||||
"labelbase",
|
||||
"userprofile",
|
||||
"notifications",
|
||||
|
||||
"bootstrapform",
|
||||
"cryptography",
|
||||
"rest_framework",
|
||||
|
|
@ -121,8 +119,6 @@ INSTALLED_APPS = [
|
|||
"hashtags",
|
||||
"statusapp",
|
||||
"attachments",
|
||||
"messages_extends",
|
||||
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -137,13 +133,9 @@ 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"
|
||||
|
||||
|
|
@ -185,11 +177,8 @@ DATABASES = {
|
|||
"USER": proj_config.get("database", "user"),
|
||||
"OPTIONS": {"charset": "utf8mb4"},
|
||||
"PASSWORD": proj_config.get("database", "password"),
|
||||
'HOST': proj_config.get("database", "host", fallback="labelbase_mysql"),
|
||||
'PORT': int(proj_config.get("database", "port", fallback="3306")),
|
||||
'OPTIONS': {
|
||||
'charset': 'utf8mb4',
|
||||
},
|
||||
'HOST': 'localhost',
|
||||
'PORT': 3306,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,19 +10,14 @@ 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
|
||||
|
||||
|
||||
from .views import (
|
||||
UTXOsHealthView,
|
||||
LabelbaseHealthDatatableView,
|
||||
LabelbaseView,
|
||||
LabelbaseViewActionView,
|
||||
LabelbaseMergeView,
|
||||
|
|
@ -40,7 +35,6 @@ from .views import (
|
|||
AboutView,
|
||||
EncryptionView,
|
||||
InteroperationalView,
|
||||
CloudView,
|
||||
ExportLabelsView,
|
||||
# StatsAndKPIView,
|
||||
TreeMapsView,
|
||||
|
|
@ -48,15 +42,8 @@ from .views import (
|
|||
LabelbaseDatatableView,
|
||||
#LabelbasePortfolioView,
|
||||
OutputStatUpdateRedirectView,
|
||||
BitcoinAddressDatatableView,
|
||||
CurrencySyncView,
|
||||
CurrencySyncActionView,
|
||||
FillMissingDataView,
|
||||
FillMissingDataActionView,
|
||||
FillOutputFieldsActionView
|
||||
)
|
||||
|
||||
|
||||
from importer.views import upload_labels
|
||||
from django.contrib.auth import views as auth_views
|
||||
|
||||
|
|
@ -102,11 +89,6 @@ 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()),
|
||||
|
|
@ -117,15 +99,6 @@ urlpatterns = [
|
|||
login_required(ProfileView.as_view()),
|
||||
name="userprofile",
|
||||
),
|
||||
path(
|
||||
"labelbase/<int:labelbase_id>/health/",
|
||||
login_required(UTXOsHealthView.as_view()),
|
||||
name="labelbase_health"
|
||||
),
|
||||
path(
|
||||
"labelbase/<int:labelbase_id>/health/data/",
|
||||
login_required(LabelbaseHealthDatatableView.as_view()),
|
||||
name="labelbase_health_label_data"),
|
||||
path(
|
||||
"labelbase/<pk>/delete/",
|
||||
login_required(LabelbaseDeleteView.as_view()),
|
||||
|
|
@ -164,30 +137,6 @@ 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()),
|
||||
|
|
@ -301,23 +250,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()),
|
||||
name='outputstat_update_redirect'
|
||||
),
|
||||
|
||||
path(
|
||||
"label-derived-addresses/<int:label_id>/",
|
||||
login_required(BitcoinAddressDatatableView.as_view()),
|
||||
name='label_derived_addresses'
|
||||
),
|
||||
|
||||
path(
|
||||
"",
|
||||
HomeView.as_view(),
|
||||
|
|
@ -339,10 +276,6 @@ 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,11 +1,6 @@
|
|||
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
|
||||
|
|
@ -32,149 +27,9 @@ 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 embit import bip32, script
|
||||
from embit.networks import NETWORKS
|
||||
|
||||
|
||||
logger = logging.getLogger('labelbase')
|
||||
|
||||
DEFAULT_DERIVE_ADDRESS_COUNT = 100
|
||||
|
||||
class BitcoinAddressDatatableView(BaseDatatableView):
|
||||
label_id = None # Variable to store label ID and verify if all is okay.
|
||||
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.label_id = kwargs.get('label_id')
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def get_initial_queryset(self):
|
||||
return self.initialize_addresses()
|
||||
|
||||
def initialize_addresses(self):
|
||||
addresses = []
|
||||
if self.label_id is not None:
|
||||
# Verify if the label belongs to the current user and get xpub
|
||||
try:
|
||||
label = Label.objects.get(id=self.label_id,
|
||||
labelbase__user_id=self.request.user.id)
|
||||
except Label.DoesNotExist:
|
||||
return []
|
||||
if label.type == "xpub":
|
||||
xpub = label.ref
|
||||
#policy_type = self.request.GET.get('policy_type', 'Single Signature')
|
||||
derivation = self.request.GET.get('derivation', 'm/84')
|
||||
offset = int(self.request.GET.get('offset', 0))
|
||||
addresses = []
|
||||
|
||||
supported_policy_types = ['Single Signature']
|
||||
supported_derivations = ['m/44', 'm/49', 'm/84']
|
||||
|
||||
#if policy_type not in supported_policy_types:
|
||||
# return []
|
||||
if derivation not in supported_derivations:
|
||||
return []
|
||||
|
||||
if not xpub:
|
||||
return []
|
||||
|
||||
key = bip32.HDKey.from_base58(xpub)
|
||||
|
||||
for i in range(int(self.request.GET.get('address_count', DEFAULT_DERIVE_ADDRESS_COUNT))):
|
||||
idx = i + offset
|
||||
if derivation == 'm/44' and xpub.startswith("xpub"):
|
||||
# BIP 44 - Legacy Addresses (P2PKH)
|
||||
pub = key.derive(f"m/0/{idx}").key
|
||||
sc = script.p2pkh(pub)
|
||||
address = sc.address(NETWORKS["main"])
|
||||
elif derivation == 'm/49' and xpub.startswith("ypub"):
|
||||
# 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["main"])
|
||||
elif derivation == 'm/84' and xpub.startswith("zpub"):
|
||||
# BIP 84 - Native SegWit Addresses (P2WPKH)
|
||||
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
|
||||
|
||||
addresses.append({
|
||||
'index': idx,
|
||||
'path': f"{derivation}'/0'/0'/0/{idx}",
|
||||
'address': address
|
||||
})
|
||||
|
||||
|
||||
return addresses
|
||||
|
||||
|
||||
def filter_queryset(self, qs):
|
||||
query = self.request.GET.get('search[value]', '').lower()
|
||||
q_qs = []
|
||||
if query:
|
||||
for item in qs:
|
||||
if query in item['address'].lower():
|
||||
q_qs.append(item)
|
||||
return q_qs
|
||||
return qs
|
||||
|
||||
|
||||
def ordering(self, qs):
|
||||
# not supported right now
|
||||
return qs
|
||||
|
||||
def paging(self, qs):
|
||||
start = int(self.request.GET.get('start', 0))
|
||||
length = int(self.request.GET.get('length', 10))
|
||||
return qs[start:start + length]
|
||||
|
||||
def prepare_results(self, qs):
|
||||
return qs
|
||||
|
||||
def count_total_records(self, qs=None):
|
||||
return int(self.request.GET.get('address_count', DEFAULT_DERIVE_ADDRESS_COUNT))
|
||||
|
||||
def count_records(self, qs):
|
||||
return len(qs)
|
||||
|
||||
def count_filtered_records(self, qs=None):
|
||||
return len(qs)
|
||||
|
||||
def render_to_response(self, context, **response_kwargs):
|
||||
qs = self.get_initial_queryset()
|
||||
filtered_qs = self.filter_queryset(qs)
|
||||
ordered_qs = self.ordering(filtered_qs)
|
||||
page_qs = self.paging(ordered_qs)
|
||||
return JsonResponse({
|
||||
"draw": int(self.request.GET.get('draw', 1)),
|
||||
"recordsTotal": self.count_total_records(qs),
|
||||
"recordsFiltered": self.count_filtered_records(filtered_qs),
|
||||
"data": page_qs
|
||||
})
|
||||
|
||||
|
||||
class AboutView(TemplateView):
|
||||
|
|
@ -189,10 +44,6 @@ class InteroperationalView(TemplateView):
|
|||
template_name = "interoperational.html"
|
||||
|
||||
|
||||
class CloudView(TemplateView):
|
||||
template_name = "cloud.html"
|
||||
|
||||
|
||||
class HomeView(TemplateView):
|
||||
template_name = "home.html"
|
||||
|
||||
|
|
@ -236,10 +87,8 @@ class LabelbaseDeleteView(DeleteView):
|
|||
class LabelbaseDatatableView(BaseDatatableView):
|
||||
model = Label
|
||||
|
||||
columns = ["id", "type", "ref", "label", "origin", "spendable",
|
||||
"height", "time", "fee", "value", "rate",
|
||||
"keypath", "fmv", "heights"
|
||||
]
|
||||
columns = ["id", "type", "ref", "label", "origin", "spendable"]
|
||||
|
||||
order_columns = ["id", "type", "ref", "label", "origin", "spendable"]
|
||||
|
||||
max_display_length = 100
|
||||
|
|
@ -302,7 +151,6 @@ 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 = []
|
||||
|
|
@ -320,84 +168,6 @@ class LabelbaseDatatableView(BaseDatatableView):
|
|||
if record.origin and search in record.origin.lower():
|
||||
res_ids.append(record.id)
|
||||
continue
|
||||
qs = qs.filter(id__in=res_ids)
|
||||
if type_filter and type_filter != 'all':
|
||||
qs = qs.filter(type=type_filter)
|
||||
return qs
|
||||
|
||||
|
||||
|
||||
class LabelbaseHealthDatatableView(BaseDatatableView):
|
||||
columns = ["id", "ref", "label", "value"]
|
||||
|
||||
order_columns = ["id", "ref", "label"]
|
||||
|
||||
def get_initial_queryset(self):
|
||||
labelbase_id = self.kwargs["labelbase_id"]
|
||||
|
||||
search_tag = self.request.GET.get('tag', None)
|
||||
|
||||
qs = Label.objects.filter(type=Label.TYPE_OUTPUT,
|
||||
labelbase__user_id=self.request.user.id,
|
||||
labelbase_id=labelbase_id).order_by("id")
|
||||
|
||||
if search_tag:
|
||||
# Due to encryption, we need to use a super slow process here...
|
||||
res_ids = []
|
||||
search = f'#{search_tag}'
|
||||
|
||||
for record in qs:
|
||||
if record.label and search in record.label:
|
||||
res_ids.append(record.id)
|
||||
continue
|
||||
qs = qs.filter(id__in=res_ids)
|
||||
return qs
|
||||
|
||||
def render_column(self, row, column):
|
||||
if column == 'id':
|
||||
return f'<tt><a href="{reverse("edit_label", args=[row.id])}">{row.id}</a></tt>'
|
||||
elif column == 'type':
|
||||
return "<tt>{}</tt>".format(row.get_type_display())
|
||||
elif column == 'ref':
|
||||
ctx = {
|
||||
'row': row,
|
||||
'mempool_url': row.get_mempool_url(),
|
||||
}
|
||||
return render_to_string('labelbase_dt_ref.html', context=ctx, request=self.request)
|
||||
elif column == 'label':
|
||||
if row.label is None:
|
||||
return ""
|
||||
if row.labelbase.user.profile.use_hashtags:
|
||||
return hashtag_to_badge(f'<tt>{row.label}</tt>')
|
||||
else:
|
||||
return f'<tt>{row.label}</tt>'
|
||||
elif column == 'value':
|
||||
try:
|
||||
return row.get_finance_output_metrics_dict().get('value')
|
||||
except Exception as ex:
|
||||
return "{}".format(ex)
|
||||
else:
|
||||
return super(LabelbaseHealthDatatableView, self).render_column(row, column)
|
||||
|
||||
def filter_queryset(self, qs):
|
||||
search = self.request.GET.get('search[value]', None)
|
||||
if search:
|
||||
# Due to encryption, we need to use a super slow process here...
|
||||
res_ids = []
|
||||
search = search.lower()
|
||||
for record in qs:
|
||||
if record.type and search in record.type.lower():
|
||||
res_ids.append(record.id)
|
||||
continue
|
||||
if record.ref and search in record.ref.lower():
|
||||
res_ids.append(record.id)
|
||||
continue
|
||||
if record.label and search in record.label.lower():
|
||||
res_ids.append(record.id)
|
||||
continue
|
||||
if search in "{}".format(record.get_finance_output_metrics_dict().get('value')):
|
||||
res_ids.append(record.id)
|
||||
continue
|
||||
return qs.filter(id__in=res_ids)
|
||||
return qs
|
||||
|
||||
|
|
@ -444,32 +214,6 @@ class LabelbaseView(ListView):
|
|||
return HttpResponseRedirect(label.labelbase.get_absolute_url())
|
||||
|
||||
|
||||
|
||||
class UTXOsHealthView(ListView):
|
||||
template_name = "utxos_health.html"
|
||||
context_object_name = "label_list"
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Label.objects.filter(
|
||||
type=Label.TYPE_OUTPUT,
|
||||
labelbase__user_id=self.request.user.id,
|
||||
labelbase_id=self.kwargs["labelbase_id"],
|
||||
)
|
||||
return qs.order_by("id")
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
labelbase_id = self.kwargs["labelbase_id"]
|
||||
context = super(UTXOsHealthView, self).get_context_data(**kwargs)
|
||||
context["labelbase"] = get_object_or_404(
|
||||
Labelbase, id=labelbase_id, user_id=self.request.user.id
|
||||
)
|
||||
context["active_labelbase_id"] = labelbase_id
|
||||
context["labelform"] = LabelForm(
|
||||
request=self.request, labelbase_id=labelbase_id
|
||||
)
|
||||
context["api_token"] = Token.objects.get(user_id=self.request.user.id)
|
||||
return context
|
||||
|
||||
class LabelbaseMergeView(LabelbaseView):
|
||||
template_name = "labelbase_merge.html"
|
||||
|
||||
|
|
@ -549,9 +293,9 @@ class TreeMapsView(ListView):
|
|||
|
||||
|
||||
# Store nearest price information.
|
||||
# TODO: This should (or could) be done on new blocks too.
|
||||
# TODO: This should be done on new blocks.
|
||||
from finances.models import HistoricalPrice
|
||||
HistoricalPrice.get_or_create_from_api(self.request.user, -1)
|
||||
HistoricalPrice.get_or_create_from_api(-1)
|
||||
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
|
|
@ -564,7 +308,7 @@ class TreeMapsView(ListView):
|
|||
context['action'] = self.kwargs.get('action', 'unspent-outputs')
|
||||
return context
|
||||
|
||||
def get_queryset_OLD(self):
|
||||
def get_queryset(self):
|
||||
#action = self.kwargs.get('action', 'unspent-outputs')
|
||||
label_ids = []
|
||||
|
||||
|
|
@ -596,38 +340,6 @@ 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"
|
||||
|
|
@ -761,210 +473,6 @@ 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"]
|
||||
|
|
@ -1024,54 +532,11 @@ 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)
|
||||
|
|
@ -1100,29 +565,19 @@ class ExportLabelsView(View):
|
|||
|
||||
class LabelUpdateView(UpdateView):
|
||||
model = Label
|
||||
fields = [
|
||||
"type", "ref", "label", "origin", "spendable",
|
||||
"height", "time", "fee", "value", "rate",
|
||||
"keypath", "fmv", "heights"
|
||||
]
|
||||
|
||||
def get_object(self):
|
||||
user_id = self.request.user.id
|
||||
pk = self.kwargs["pk"]
|
||||
return get_object_or_404(Label, labelbase__user_id=self.request.user.id, pk=pk)
|
||||
fields = ["type", "ref", "label", "origin", "spendable"]
|
||||
|
||||
def get_template_names(self):
|
||||
if 'action' in self.kwargs:
|
||||
action = self.kwargs['action']
|
||||
if action == 'labeling':
|
||||
return "label_edit_labeling.html"
|
||||
elif action == 'attachments' and settings.SELF_HOSTED and \
|
||||
if action == 'attachments' and settings.SELF_HOSTED and \
|
||||
self.object.labelbase.user.profile.use_attachments:
|
||||
return "label_edit_attachments.html"
|
||||
elif action == "derive-addresses":
|
||||
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):
|
||||
|
|
@ -1130,42 +585,18 @@ 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"]:
|
||||
if context["action"] == "labeling":
|
||||
context["labelform"] = LabelForm(
|
||||
request=self.request, labelbase_id=self.object.labelbase.id
|
||||
)
|
||||
if self.object.type == "tx":
|
||||
# used by "labeling"
|
||||
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":
|
||||
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
|
||||
context["output"] = OutputStat.objects.filter(
|
||||
user=self.object.labelbase.user,
|
||||
type_ref_hash=self.object.type_ref_hash).last()
|
||||
|
||||
return context
|
||||
|
||||
|
|
@ -1224,13 +655,6 @@ 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:
|
||||
|
|
@ -1250,7 +674,7 @@ class OutputStatUpdateRedirectView(View):
|
|||
)
|
||||
return redirect("home")
|
||||
|
||||
spent = request.GET.get('force-spent', -1)
|
||||
spent = request.GET.get('force-spent', None)
|
||||
|
||||
if spent not in ["true", "false", "none"]:
|
||||
messages.add_message(
|
||||
|
|
@ -1273,181 +697,3 @@ class OutputStatUpdateRedirectView(View):
|
|||
)
|
||||
label.save() # will trigger a check agains Electrum
|
||||
return redirect('edit_label', pk=label_id)
|
||||
|
||||
|
||||
class FillMissingDataView(View):
|
||||
template_name = "fill_missing_data.html"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
labelbase_id = self.kwargs["labelbase_id"]
|
||||
labelbase = get_object_or_404(Labelbase, id=labelbase_id, user_id=request.user.id)
|
||||
|
||||
# Get all output labels (only type that uses OutputStat)
|
||||
output_labels = Label.objects.filter(
|
||||
labelbase_id=labelbase_id,
|
||||
type='output'
|
||||
)
|
||||
|
||||
can_fill_from_outputstat = []
|
||||
already_complete = []
|
||||
|
||||
for label in output_labels:
|
||||
missing_fields = self._get_missing_fields(label)
|
||||
if not missing_fields:
|
||||
already_complete.append(label)
|
||||
continue
|
||||
|
||||
# Check if OutputStat exists
|
||||
output_stat = OutputStat.objects.filter(
|
||||
user=label.labelbase.user,
|
||||
type_ref_hash=label.type_ref_hash,
|
||||
network=label.labelbase.network
|
||||
).first()
|
||||
|
||||
if output_stat:
|
||||
can_fill_from_outputstat.append({
|
||||
'label': label,
|
||||
'missing_fields': missing_fields,
|
||||
'output_stat': output_stat
|
||||
})
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'labelbase': labelbase,
|
||||
'can_fill_from_outputstat': can_fill_from_outputstat,
|
||||
'already_complete': already_complete,
|
||||
'active_labelbase_id': labelbase_id,
|
||||
})
|
||||
|
||||
def _get_missing_fields(self, label):
|
||||
"""Return list of fields that are missing for output type"""
|
||||
missing = []
|
||||
|
||||
# Only check fields applicable to outputs
|
||||
applicable_fields = ['height', 'time', 'value']
|
||||
|
||||
for field in applicable_fields:
|
||||
value = getattr(label, field, None)
|
||||
if not value or (isinstance(value, str) and not value.strip()):
|
||||
missing.append(field)
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
|
||||
class FillMissingDataActionView(View):
|
||||
def post(self, request, *args, **kwargs):
|
||||
labelbase_id = self.kwargs["labelbase_id"]
|
||||
action = request.POST.get('action')
|
||||
label_id = request.POST.get('label_id')
|
||||
if action == 'fill_all_from_outputstat':
|
||||
count = self._fill_all_from_outputstat(request.user.id, labelbase_id)
|
||||
messages.success(request, f"Filled {count} labels from OutputStat data.")
|
||||
elif action == 'fill_single_from_outputstat':
|
||||
label = get_object_or_404(Label, id=label_id, labelbase__user_id=request.user.id)
|
||||
if self._fill_label_from_outputstat(label):
|
||||
messages.success(request, "Filled label data from OutputStat.")
|
||||
else:
|
||||
messages.error(request, "Could not fill label data.")
|
||||
return HttpResponseRedirect(reverse('fill_missing_data', kwargs={'labelbase_id': labelbase_id}))
|
||||
|
||||
def _fill_all_from_outputstat(self, user_id, labelbase_id):
|
||||
"""Fill all output labels that have OutputStat data"""
|
||||
labels = Label.objects.filter(
|
||||
labelbase_id=labelbase_id,
|
||||
labelbase__user_id=user_id,
|
||||
type='output'
|
||||
)
|
||||
count = 0
|
||||
for label in labels:
|
||||
if self._fill_label_from_outputstat(label):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def _fill_label_from_outputstat(self, label):
|
||||
"""Fill a single label from OutputStat data"""
|
||||
output_stat = OutputStat.objects.filter(
|
||||
user=label.labelbase.user,
|
||||
type_ref_hash=label.type_ref_hash,
|
||||
network=label.labelbase.network
|
||||
).first()
|
||||
|
||||
if not output_stat:
|
||||
return False
|
||||
|
||||
updated = False
|
||||
|
||||
# Fill height (only if empty)
|
||||
if not label.height and output_stat.confirmed_at_block_height:
|
||||
label.height = str(output_stat.confirmed_at_block_height)
|
||||
updated = True
|
||||
|
||||
# Fill time (only if empty)
|
||||
if not label.time and output_stat.confirmed_at_block_time:
|
||||
# Convert Unix timestamp to ISO-8601
|
||||
dt = datetime.utcfromtimestamp(output_stat.confirmed_at_block_time)
|
||||
label.time = dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
updated = True
|
||||
|
||||
# Fill value (only if empty)
|
||||
if not label.value and output_stat.value:
|
||||
label.value = str(output_stat.value)
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
label.save()
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
class FillOutputFieldsActionView(View):
|
||||
"""Single output field fill action from output-details page"""
|
||||
def post(self, request, *args, **kwargs):
|
||||
label_id = self.kwargs["label_id"]
|
||||
label = get_object_or_404(
|
||||
Label,
|
||||
id=label_id,
|
||||
labelbase__user_id=request.user.id,
|
||||
type='output'
|
||||
)
|
||||
|
||||
# Reuse the existing fill logic
|
||||
if self._fill_label_from_outputstat(label):
|
||||
messages.success(request, "Successfully filled BIP-329 fields from OutputStat data.")
|
||||
else:
|
||||
messages.error(request, "Could not fill fields. OutputStat data may not be available.")
|
||||
|
||||
return HttpResponseRedirect(
|
||||
reverse('edit_label', kwargs={'pk': label_id}) + '?action=output-details'
|
||||
)
|
||||
|
||||
def _fill_label_from_outputstat(self, label):
|
||||
"""Fill a single label from OutputStat data (reused from FillMissingDataActionView)"""
|
||||
output_stat = OutputStat.objects.filter(
|
||||
user=label.labelbase.user,
|
||||
type_ref_hash=label.type_ref_hash,
|
||||
network=label.labelbase.network
|
||||
).first()
|
||||
|
||||
if not output_stat:
|
||||
return False
|
||||
|
||||
updated = False
|
||||
|
||||
if not label.height and output_stat.confirmed_at_block_height:
|
||||
label.height = str(output_stat.confirmed_at_block_height)
|
||||
updated = True
|
||||
|
||||
if not label.time and output_stat.confirmed_at_block_time:
|
||||
dt = datetime.utcfromtimestamp(output_stat.confirmed_at_block_time)
|
||||
label.time = dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
updated = True
|
||||
|
||||
if not label.value and output_stat.value:
|
||||
label.value = str(output_stat.value)
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
label.save()
|
||||
|
||||
return updated
|
||||
|
|
|
|||
|
|
@ -1,268 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
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
|
||||
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# -*- 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)
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
# -*- 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
|
||||
)
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# -*- 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"
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
# -*- 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
# -*- 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)
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
$(function() {
|
||||
$("a.close[close-href]").click(function (e) {
|
||||
e.preventDefault();
|
||||
$.post($(this).attr("close-href"), "", function () {
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1,231 +0,0 @@
|
|||
# -*- 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]
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{% 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 %}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
# -*- 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'),
|
||||
]
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# -*- 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('')
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
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,14 +1,12 @@
|
|||
gunicorn
|
||||
asgiref
|
||||
certifi
|
||||
cffi
|
||||
charset-normalizer
|
||||
asgiref==3.4.1
|
||||
certifi==2023.07.22
|
||||
cffi==1.15.1
|
||||
charset-normalizer==2.0.12
|
||||
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
|
||||
|
|
@ -25,10 +23,10 @@ django-phonenumber-field==6.0.0
|
|||
django-sekizai==2.0.0
|
||||
django-user-sessions==2.0.0
|
||||
djangorestframework==3.14.0
|
||||
idna==3.7
|
||||
idna==3.4
|
||||
importlib-metadata==4.8.3
|
||||
itypes==1.2.0
|
||||
Jinja2==3.1.4
|
||||
Jinja2==3.1.3
|
||||
Markdown==3.3.7
|
||||
MarkupSafe==2.0.1
|
||||
mysqlclient>=2.0,<3
|
||||
|
|
@ -38,12 +36,12 @@ qrcode==7.3.1
|
|||
requests==2.31.0
|
||||
sentry-sdk==1.43.0
|
||||
six==1.16.0
|
||||
sqlparse==0.5.0
|
||||
sqlparse==0.4.4
|
||||
typing_extensions==4.1.1
|
||||
uritemplate==4.1.1
|
||||
urllib3>=2.6.0
|
||||
urllib3==1.26.18
|
||||
zipp==3.6.0
|
||||
bip329==1.0.0
|
||||
bip329==0.0.3
|
||||
pymempool==0.0.5
|
||||
django-datatables-view==1.20.0
|
||||
django-money==3.3
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
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'))
|
||||
|
|
@ -19,14 +19,8 @@
|
|||
<meta name="twitter:description" value="All-in-one coin control/label management service for Bitcoin. Add, import, export labels. REST API for integration. Supports BIP-329 format. Stay organized!" />
|
||||
<meta name="twitter:image" content="https://labelbase.space{% static 'ogimg.png' %}" />
|
||||
<meta name="twitter:url" value="https://labelbase.space/" />
|
||||
<title>{% if user.is_authenticated %}
|
||||
{% for labelbase in user.profile.labelbases %}
|
||||
{% if labelbase.id == active_labelbase_id %}
|
||||
{% if labelbase.name %}{{ labelbase.name }}{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% block title %}{% endblock %} | Labelbase</title>
|
||||
|
||||
<title>{% block title %}{% endblock %} | Labelbase</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet"
|
||||
|
|
@ -41,26 +35,6 @@
|
|||
{% 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>
|
||||
|
|
@ -90,6 +64,7 @@
|
|||
<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 %}
|
||||
|
|
@ -103,15 +78,6 @@
|
|||
</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">
|
||||
|
|
@ -137,22 +103,8 @@
|
|||
<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">
|
||||
|
||||
|
|
@ -178,19 +130,13 @@
|
|||
{% if labelbase.id == active_labelbase_id %}
|
||||
<ul style="list-style-type: none;">
|
||||
<li><a class="nav-link" href="{{ labelbase.get_absolute_url }}" style="font-size: .875rem;"><span data-feather="tag" class="align-text-bottom"></span> Labels</a></li>
|
||||
{% with xpub_url=labelbase.get_xpub_url %}
|
||||
{% if xpub_url %}
|
||||
<li><a class="nav-link" href="{{ xpub_url }}derive-addresses/" style="font-size: .875rem;"><span data-feather="list" class="align-text-bottom"></span> Addresses</a></li>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_fix_and_manage" labelbase_id=labelbase.id %}"><span data-feather="git-merge" class="align-text-bottom"></span> Fix & Manage</a></li>
|
||||
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "fill_missing_data" labelbase_id=labelbase.id %}"><span data-feather="refresh-cw" class="align-text-bottom"></span> Sync Fields</a></li>
|
||||
{% if request.user.profile.use_fiatfinances %}
|
||||
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "currency_sync" labelbase_id=labelbase.id %}"><span data-feather="dollar-sign" class="align-text-bottom"></span> Fiat Finances</a></li>
|
||||
{% endif %}
|
||||
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_health" labelbase_id=labelbase.id %}"><span data-feather="activity" class="align-text-bottom"></span>UTXOs Health</a></li>{% endcomment %}
|
||||
{# <li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_fix_and_manage" 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 "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 %}
|
||||
|
|
@ -253,9 +199,11 @@
|
|||
<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>
|
||||
|
|
@ -344,51 +292,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="{% 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="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/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 */
|
||||
|
|
@ -576,6 +524,13 @@
|
|||
loadChat(false);
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -14,10 +14,6 @@
|
|||
</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>
|
||||
|
|
@ -59,7 +55,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.3</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.1.0</a></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -95,6 +95,11 @@
|
|||
<div class="carousel-item">
|
||||
<h5>Backups</h5>
|
||||
<p>Before upgrading to a newer version, backup your data by exporting your labels as a BIP-329 file or an encrypted archive.</p>
|
||||
</div>
|
||||
|
||||
<div class="carousel-item">
|
||||
<h5>Yellow Screen</h5>
|
||||
<p>In this new release, you will see a yellow screen in case of an error. Please create a screenshot and share it with us to help improve Labelbase.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,42 +8,37 @@
|
|||
{% 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 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>
|
||||
|
||||
|
||||
|
||||
|
||||
<!--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 %}
|
||||
<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" %}">
|
||||
Update spent Outputs
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="btn-group" role="group">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{% load bootstrap %}
|
||||
|
||||
<!-- modal -->
|
||||
<div class="modal" tabindex="-1" id="addLabelModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
|
|
@ -13,89 +14,13 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{% 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,19 +18,9 @@
|
|||
<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>
|
||||
|
|
@ -41,34 +31,11 @@
|
|||
</div>
|
||||
|
||||
{% addtoblock "js" %}
|
||||
|
||||
$(document).ready(function () {
|
||||
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);
|
||||
});
|
||||
var form = $('#importLabelbaseModal form');
|
||||
form.on('submit', function () {
|
||||
// Disable the OK button on form submission
|
||||
$('#importLabelbaseModal button[type="submit"]').prop('disabled', true);
|
||||
});
|
||||
|
||||
});
|
||||
{% endaddtoblock %}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{% extends "attachments/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% include "attachments/add_form.html" %}
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
@ -8,5 +8,7 @@
|
|||
{{ form|bootstrap }}
|
||||
<br>
|
||||
<input type="submit" class="btn btn-primary" value="{% trans "Add attachment" %}"/>
|
||||
|
||||
</form>
|
||||
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
{% 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 %}
|
||||
|
|
@ -1,212 +0,0 @@
|
|||
{% 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,106 +7,22 @@
|
|||
|
||||
{% 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">
|
||||
|
||||
|
||||
<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.
|
||||
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!
|
||||
</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,7 +22,6 @@
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
{% 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>
|
||||
|
|
@ -18,8 +18,11 @@
|
|||
<!-- Centered "Getting Started" CTA Block -->
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-4 rounded-3">
|
||||
<img src="/static/Encryption.png" class="card-img-top" style="padding: 0.25em;">
|
||||
<div class="card mb-4 rounded-3 shadow-sm">
|
||||
<img src="/static/Encryption.png"
|
||||
class="card-img-top"
|
||||
alt="..."
|
||||
style="padding: 0.25em;">
|
||||
<div class="card-body">
|
||||
<p style="font-weight: 700;">Your labels are encrypted.<br> </p>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
{% 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 %}
|
||||
|
|
@ -268,11 +268,27 @@
|
|||
|
||||
<div class="col d-flex align-items-start">
|
||||
<div>
|
||||
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Attachments</h3>
|
||||
<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>
|
||||
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Cloud-Hosted</h3>
|
||||
<p>Experience the flexibility and accessibility of Labelbase's cloud-hosted solution, providing users with a reliable and efficient label management service hosted on secure and scalable cloud infrastructure.</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>
|
||||
|
|
@ -287,47 +303,6 @@
|
|||
</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,8 +53,7 @@ code {
|
|||
{% endfor %}
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="fs-6">
|
||||
{{ article.content | markdown | safe }}
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
{#% 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">
|
||||
|
||||
|
|
@ -30,5 +31,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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
{% extends "label_edit.html" %}
|
||||
{% load bootstrap %}
|
||||
{% load sekizai_tags %}
|
||||
{% load i18n %}
|
||||
{% load labelbase_tags %}
|
||||
|
||||
|
||||
{% block label_edit_content %}
|
||||
|
||||
{% include "_modal_add_label.html" %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="lb-header mt-3">
|
||||
<div class="card-header collapsed" data-bs-toggle="collapse" data-bs-target="#collapse_empty_labels" aria-expanded="false">
|
||||
<h4 class="mb-4 d-flex justify-content-between align-items-center">
|
||||
<span>Address Derivation Configuration</span>
|
||||
<!--button class="btn btn-primary ">Configure</button-->
|
||||
</h4>
|
||||
You can create new labels by using the 'New "addr" Label' button on the right side, or <a href="javascript:void(0);">change configuration</a>.
|
||||
|
||||
{% switch request.GET.derivation %}
|
||||
{% case "m/44" %}
|
||||
<p>Currently, {{ address_count }} BIP 44 - Legacy Addresses (P2PKH) addresses are derived using derivation path m/44'/0'/0'.</p>
|
||||
{% case "m/49" %}
|
||||
<p>Currently, {{ address_count }} BIP 49 - SegWit Addresses (P2SH) addresses are derived using derivation path m/49'/0'/0'.</p>
|
||||
{% case "m/84" %}
|
||||
<p>Currently, {{ address_count }} BIP 84 - Native SegWit Addresses (Bech32) addresses are derived using derivation path m/84'/0'/0'.</p>
|
||||
{% else %}
|
||||
<p>Configuration not loaded.</p>
|
||||
{% endswitch %}
|
||||
|
||||
</div>
|
||||
<div id="collapse_empty_labels" class="collapse">
|
||||
<div class="card-body">
|
||||
|
||||
<form action="{% url 'edit_label' object.id %}derive-addresses/" method="GET">
|
||||
<div class="mb-3">
|
||||
<label for="derivationPath" class="form-label">Derivation Path and Script Type </label>
|
||||
<select class="form-select" id="derivationPath" name="derivation"
|
||||
{% switch request.GET.derivation %}
|
||||
{% case "m/44" %}
|
||||
disabled>
|
||||
<option value="m/44" selected>BIP 44 - Legacy Addresses (P2PKH)</option>
|
||||
<option value="m/49" >BIP 49 - SegWit Addresses (P2SH)</option>
|
||||
<option value="m/84" >BIP 84 - Native SegWit Addresses (Bech32)</option>
|
||||
{% case "m/49" %}
|
||||
disabled>
|
||||
<option value="m/44" >BIP 44 - Legacy Addresses (P2PKH)</option>
|
||||
<option value="m/49" selected>BIP 49 - SegWit Addresses (P2SH)</option>
|
||||
<option value="m/84" >BIP 84 - Native SegWit Addresses (Bech32)</option>
|
||||
{% case "m/84" %}
|
||||
disabled>
|
||||
<option value="m/44" >BIP 44 - Legacy Addresses (P2PKH)</option>
|
||||
<option value="m/49" >BIP 49 - SegWit Addresses (P2SH)</option>
|
||||
<option value="m/84" selected>BIP 84 - Native SegWit Addresses (Bech32)</option>
|
||||
{% else %}
|
||||
> {# keep this! #}
|
||||
<option value="m/44" {% if form.instance.ref|slice:":4" == "xpub" %}selected{% endif %}>BIP 44 - Legacy Addresses (P2PKH)</option>
|
||||
<option value="m/49" {% if form.instance.ref|slice:":4" == "ypub" %}selected{% endif %}>BIP 49 - SegWit Addresses (P2SH)</option>
|
||||
<option value="m/84" {% if form.instance.ref|slice:":4" == "zpub" %}selected{% endif %}>BIP 84 - Native SegWit Addresses (Bech32)</option>
|
||||
{% endswitch %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="amountOut" class="form-label">Amount of Addresses</label>
|
||||
<input type="number" class="form-control" id="amountOut" name="address_count" value="{{ address_count }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="offset" class="form-label">Offset Index</label>
|
||||
<input type="number" class="form-control" id="offset" name="offset" value="{{ offset }}" required {#placeholder="Leave blank for next available index"#}>
|
||||
</div>
|
||||
<button type="submit" id="submit-config" class="btn btn-primary">Update Derived Addresses</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ form.instance.ref }}
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="table-responsive" style="padding-top:1em" >
|
||||
<table id="derived_addesses" class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"></th>
|
||||
<th scope="col"></th>
|
||||
<th scope="col"></th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
{% addtoblock "js" %}
|
||||
$(document).ready(function () {
|
||||
{% if request.GET.derivation %}
|
||||
|
||||
|
||||
const dt_table = $('#derived_addesses').DataTable({
|
||||
order: [[0, "asc"]],
|
||||
columns: [
|
||||
{ data: 'index', title: 'Index', orderable: false, searchable: true },
|
||||
{ data: 'path', title: 'Path', orderable: false, searchable: true },
|
||||
{
|
||||
data: 'address',
|
||||
title: 'Address',
|
||||
orderable: false,
|
||||
searchable: false,
|
||||
render: function(data, type, row) {
|
||||
return `${data} <a href="javascript:void(0);" class="btn btn-outline-primary btn-sm open-modal"
|
||||
data-label=""
|
||||
data-type="addr"
|
||||
style="float:right;"
|
||||
data-ref="${data}">New "addr" Label</a>`;
|
||||
}
|
||||
}
|
||||
],
|
||||
searching: true,
|
||||
processing: false,
|
||||
serverSide: true,
|
||||
stateSave: true,
|
||||
responsive: true,
|
||||
ajax: {
|
||||
{% with policy_type=request.GET.policy_type|default:"Single Signature" derivation=request.GET.derivation|default:"m/84" address_count=request.GET.address_count|default:"{{ address_count }}" offset=request.GET.offset|default:"{{ offset }}" %}
|
||||
url: "{% url 'label_derived_addresses' form.instance.id %}?policy_type={{ policy_type }}&derivation={{ derivation }}&address_count={{ address_count }}",
|
||||
{% endwith %}
|
||||
type: 'GET',
|
||||
dataSrc: 'data'
|
||||
}
|
||||
});
|
||||
{% else %}
|
||||
$("#submit-config").click();
|
||||
{% endif %}
|
||||
|
||||
$('#derived_addesses').on('click', '.open-modal', function() {
|
||||
var label = $(this).data('label');
|
||||
var type = $(this).data('type');
|
||||
var ref = $(this).data('ref');
|
||||
|
||||
$('#id_label').val(label);
|
||||
$('#id_type').val(type);
|
||||
$('#id_ref').val(ref);
|
||||
|
||||
$('#addLabelModal').modal('show');
|
||||
$('#id_type').change(); // update readonly fields based on type
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
{% endaddtoblock %}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -5,71 +5,106 @@
|
|||
{% load backgroundtask_tags %}
|
||||
{% load attachments_tags %}
|
||||
|
||||
{% block title %}{{ object.type }} {{ object.ref }}{% endblock %}
|
||||
{% block title %}{{ object.type }} {{ object.ref }}{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% get_attachments_for object.get_label_attachment as my_attachments %}
|
||||
<ul class="nav nav-tabs" style="padding-top: 2rem; ">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if action == "update" %}active" aria-current="page" {% else %}"{% endif %} href="{% url 'edit_label' object.id %}">Label Detail</a>
|
||||
</li>
|
||||
|
||||
<div class="row">
|
||||
<ul class="nav nav-tabs" style="padding-top: 2rem;">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if action == "update" %}active{% endif %}" aria-current="page" href="{% url 'edit_label' object.id %}">Label Detail</a>
|
||||
</li>
|
||||
{% is_self_hosted as my_labelbase_is_self_hosted %}
|
||||
{% if object.labelbase.user.profile.use_attachments %}
|
||||
<li class="nav-item">
|
||||
{% if my_labelbase_is_self_hosted %}
|
||||
<a class="nav-link {% if action == "attachments" %}active " aria-current="page" {% else %}"{% endif %}
|
||||
href="{% url 'edit_label' object.id %}attachments/">
|
||||
Attachments
|
||||
{% if my_attachments.count %}
|
||||
<span class="badge text-bg-secondary top-0 rounded-pill " >{% attachments_count object.get_label_attachment %}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
|
||||
<a class="nav-link disabled">Attachments</a>
|
||||
|
||||
{% if object.type == "tx" %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if action == "labeling" %}active{% endif %}" href="{% url 'edit_label' object.id %}labeling/">Transaction Labeling</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% comment %}<!--
|
||||
|
||||
{% if object.type == "output" %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if action == "output-details" %}active{% endif %}" href="{% url 'edit_label' object.id %}output-details/">Output Details</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<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>
|
||||
-->{% endcomment %}
|
||||
|
||||
{% if object.type == "xpub" %}
|
||||
<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>
|
||||
{% endswitch %}
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if object.labelbase.user.profile.use_attachments %}
|
||||
<li class="nav-item">
|
||||
{% is_self_hosted as my_labelbase_is_self_hosted %}
|
||||
{% if my_labelbase_is_self_hosted %}
|
||||
<a class="nav-link {% if action == "attachments" %}active{% endif %}" aria-current="page" href="{% url 'edit_label' object.id %}attachments/">
|
||||
Attachments
|
||||
{% if my_attachments.count %}
|
||||
<span class="badge text-bg-secondary top-0 rounded-pill">{% attachments_count object.get_label_attachment %}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="nav-link disabled">Attachments</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% switch object.type %}
|
||||
{% case "addr" %}
|
||||
|
||||
{% if object.ref == "54e48e5f5c656b26c3bca14a8c95aa583d07ebe84dde3b7dd4a78f4e4186e713" %}
|
||||
{% comment %}<!--
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/static/bitcoin.pdf">Bitcoin White Paper</a>
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Transaction Output Labeling</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
-->{% endcomment %}
|
||||
|
||||
{% case "tx" %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if action == "labeling" %}active" aria-current="page" {% else %}"{% endif %} href="{% url 'edit_label' object.id %}labeling/">Transaction Labeling</a>
|
||||
</li>
|
||||
|
||||
{% 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>
|
||||
-->{% endcomment %}
|
||||
{% case "output" %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if action == "output-details" %}active" aria-current="page" {% else %}"{% endif %} href="{% url 'edit_label' object.id %}output-details/">Output Details</a>
|
||||
</li>
|
||||
|
||||
{% comment %}<!--
|
||||
|
||||
<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>
|
||||
-->{% endcomment %}
|
||||
|
||||
{% comment %}<!--
|
||||
<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>
|
||||
-->{% endcomment %}
|
||||
|
||||
|
||||
{% case "xpub" %}
|
||||
{% comment %}<!--
|
||||
<li class="nav-item">
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Coin History</a>
|
||||
</li>
|
||||
-->{% endcomment %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
{% endswitch %}
|
||||
|
||||
|
||||
|
||||
{% if object.ref == "54e48e5f5c656b26c3bca14a8c95aa583d07ebe84dde3b7dd4a78f4e4186e713" %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/static/bitcoin.pdf">Bitcoin White Paper</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
</ul>
|
||||
|
||||
<div style="padding-top: 0rem; padding-bottom: 2.1rem; ">
|
||||
|
||||
|
|
@ -89,50 +124,83 @@
|
|||
</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">
|
||||
<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 %}
|
||||
{% 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.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endcomment %}
|
||||
|
||||
{% block label_edit_content %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="modal" tabindex="-1" id="deleteLabelModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
|
|
@ -155,160 +223,13 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div
|
||||
|
||||
<script>
|
||||
{% addtoblock "js" %}
|
||||
|
||||
// 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>
|
||||
{% addtoblock "js" %}
|
||||
{% endaddtoblock %}
|
||||
</script>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -168,8 +168,6 @@
|
|||
<script>
|
||||
{% addtoblock "js" %}
|
||||
|
||||
|
||||
|
||||
$(document).ready(function() {
|
||||
$('.open-modal').click(function() {
|
||||
var label = $(this).data('label');
|
||||
|
|
|
|||
|
|
@ -1,108 +1,36 @@
|
|||
{% extends "label_edit.html" %}
|
||||
{% load bootstrap %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% block label_edit_content %}
|
||||
|
||||
{% if action == "output-details" %}
|
||||
<!--
|
||||
{{ res_tx|safe }}
|
||||
-->
|
||||
{% if action == "label-attachments" %}
|
||||
{% 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:
|
||||
|
||||
<!-- BIP-329 Fields Status Alert (right after spent status in parent template) -->
|
||||
{% if missing_fields %}
|
||||
<div class="bd-callout bd-callout-info">
|
||||
<strong>Missing BIP-329 fields:</strong>
|
||||
{% for field in missing_fields %}
|
||||
<span class="badge bg-warning text-dark">{{ field }}</span>
|
||||
{% endfor %}
|
||||
<br><small>Click the button below to populate from OutputStat data.</small>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bd-callout bd-callout-good">
|
||||
<strong>✓ All fields populated!</strong> This label has all applicable BIP-329 fields filled.
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ output.next_input_attributes }}
|
||||
|
||||
<!-- Consolidated Output Details Card -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5>Output Details & BIP-329 Fields</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-borderless">
|
||||
<tr>
|
||||
<td style="width: 30%;"><strong>Output ref:</strong></td>
|
||||
<td><tt>{{ label.ref }}</tt></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Network:</strong></td>
|
||||
<td>{{ output.get_network_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Spent status:</strong></td>
|
||||
<td>{{ output.get_spent_status }}</td>
|
||||
</tr>
|
||||
<tr class="table-light">
|
||||
<td colspan="2"><strong>BIP-329 Fields:</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>value:</strong></td>
|
||||
<td>
|
||||
{% if label.value %}
|
||||
<tt>{{ label.value }}</tt> sats
|
||||
{% else %}
|
||||
<span class="text-muted">{{ output.value }} sats (available from OutputStat)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>height:</strong></td>
|
||||
<td>
|
||||
{% if label.height %}
|
||||
<tt>{{ label.height }}</tt>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ output.confirmed_at_block_height }} (available from OutputStat)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>time:</strong></td>
|
||||
<td>
|
||||
{% if label.time %}
|
||||
<tt>{{ label.time }}</tt>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ output_block_time_utc }} UTC (available from OutputStat)</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if output.next_input_attributes %}
|
||||
<tr class="table-light">
|
||||
<td colspan="2"><strong>Fee Estimation:</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
{{ output.next_input_attributes }}
|
||||
{% if output.next_input_attributes.input_n %}
|
||||
<br><small>Assuming {{ output.next_input_attributes.input_m }}-of-{{ output.next_input_attributes.input_n }} multisig</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
<!-- 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>
|
||||
{% if output.next_input_attributes.input_n %}
|
||||
* Assuming {{ output.next_input_attributes.input_m }}-of-{{ output.next_input_attributes.input_n }} multisig
|
||||
{% else %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</pre>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
{% extends "label_edit.html" %}
|
||||
{% load bootstrap %}
|
||||
{% load i18n %}
|
||||
|
||||
{% load sekizai_tags %}
|
||||
{% load attachments_tags %}
|
||||
|
||||
{% block label_edit_content %}
|
||||
|
|
@ -35,4 +33,7 @@
|
|||
</form>
|
||||
|
||||
</div><!-- lb-header container -->
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -9,45 +9,24 @@
|
|||
|
||||
{% 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 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>
|
||||
<div class="table-responsive" style="padding-top:1em" >
|
||||
{% if label_list %}
|
||||
<table id="bip329labels" class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -57,333 +36,190 @@
|
|||
<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 %}
|
||||
|
||||
<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>
|
||||
{% if labelbase %}
|
||||
|
||||
{% include "_modal_add_label.html" %}
|
||||
{% include "_modal_edit_labelbase.html" %}
|
||||
{% include "_modal_connect_api_key.html" %}
|
||||
{% include "_modal_delete_labelbase.html" %}
|
||||
|
||||
<style>
|
||||
@media (max-width: 767.98px) {
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
<!-- 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>
|
||||
|
||||
.pagination .page-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination .page-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
<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-item:first-child .page-link,
|
||||
.pagination .page-item:last-child .page-link {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
<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>
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-text strong {
|
||||
display: inline-block;
|
||||
min-width: 80px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.td-key {
|
||||
width: 6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
<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}
|
||||
|
||||
],
|
||||
|
||||
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'
|
||||
const dt_table = $('#bip329labels').dataTable({
|
||||
order: [[0, "asc"]],
|
||||
columns: [
|
||||
{
|
||||
data: 'id',
|
||||
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());
|
||||
}
|
||||
{
|
||||
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,
|
||||
}
|
||||
});
|
||||
|
||||
// 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>`;
|
||||
}
|
||||
],
|
||||
searching: true,
|
||||
processing: false,
|
||||
serverSide: true,
|
||||
stateSave: true,
|
||||
responsive: true,
|
||||
ajax: {
|
||||
url: "{% url 'labelbase_label_data' labelbase.pk %}?tag={{ request.GET.tag }}",
|
||||
type: 'GET'
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
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 }}'
|
||||
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,12 +16,6 @@
|
|||
<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,26 +11,14 @@
|
|||
<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 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 %}
|
||||
|
||||
{% if 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>
|
||||
|
|
@ -40,7 +28,6 @@ If the UTXO value is not provided, Labelbase will estimate it based on historica
|
|||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row ">
|
||||
|
||||
<style>
|
||||
|
|
@ -53,6 +40,8 @@ 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) {
|
||||
|
|
@ -64,6 +53,7 @@ function colorFromValue(value, index, border) {
|
|||
} else {
|
||||
var color = "#F7931A";
|
||||
}
|
||||
//var color = "orange";
|
||||
if (border) {
|
||||
alpha += 0.01;
|
||||
}
|
||||
|
|
@ -72,11 +62,15 @@ 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) {
|
||||
|
|
@ -94,39 +88,12 @@ function red_or_greem_fromValue(value, index, border) {
|
|||
.rgbString();
|
||||
}
|
||||
|
||||
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" or action == "fee-efficiency" %}
|
||||
{% if action == "unspent-spendable-outputs" and label.spendable or action == "unspent-outputs" %}
|
||||
{
|
||||
'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 }}',
|
||||
|
|
@ -138,24 +105,23 @@ 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" }},
|
||||
'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 %}
|
||||
'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
|
||||
|
||||
},
|
||||
{% 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, {
|
||||
|
|
@ -167,18 +133,10 @@ data: {
|
|||
data: window.records,
|
||||
key: 'sats',
|
||||
backgroundColor: function(ctx) {
|
||||
{% 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 %}
|
||||
return red_or_greem_fromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex);
|
||||
},
|
||||
borderColor: function(ctx) {
|
||||
{% 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 %}
|
||||
return red_or_greem_fromValue(ctx.dataset.data[ctx.dataIndex].v, ctx.dataIndex, true);
|
||||
},
|
||||
spacing: 1,
|
||||
borderWidth: 0,
|
||||
|
|
@ -190,7 +148,7 @@ options: {
|
|||
maintainAspectRatio: false,
|
||||
title: {
|
||||
display: true,
|
||||
text: "{% if action == 'fee-efficiency' %}Fee Efficiency (VTER){% elif action == 'unspent-spendable-outputs' %}Unspent Spendable Outputs{% else %}Unspent Outputs{% endif %}"
|
||||
text: "Unspent {% if action == "unspent-spendable-outputs" %}Spendable {% endif %}Outputs"
|
||||
},
|
||||
legend: {
|
||||
display: false
|
||||
|
|
@ -202,6 +160,8 @@ options: {
|
|||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
document.getElementById("chart-area").onclick = function(evt) {
|
||||
var activePoints = window.chart1.getElementsAtEventForMode(evt, 'nearest', { intersect: true }, true);
|
||||
if (activePoints[0]) {
|
||||
|
|
@ -209,26 +169,30 @@ 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>';
|
||||
|
|
@ -240,6 +204,9 @@ 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;
|
||||
}
|
||||
|
|
@ -276,13 +243,16 @@ 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);
|
||||
leftPosition = Math.max(leftPosition, 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
|
||||
|
||||
// Ensure that the top position is within the viewport
|
||||
if (topPosition < 0) {
|
||||
topPosition = 0;
|
||||
} else if (topPosition + popoverHeight > window.innerHeight) {
|
||||
|
|
@ -314,16 +284,35 @@ 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,7 +69,16 @@
|
|||
<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>
|
||||
|
|
@ -96,7 +105,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,10 +17,6 @@
|
|||
{% 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 %}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
{% 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 %}
|
||||
<h2 style="padding-top: 1em;">{% block title %}{% trans "Logged Out" %}{% endblock %}</h2>
|
||||
<h1>{% block title %}{% trans "Logged Out" %}{% endblock %}</h1>
|
||||
<p>{% trans "See you around!" %}</p>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,13 @@
|
|||
<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' %}
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
# User: Troy Evans
|
||||
# Date: 1/24/13
|
||||
# Time: 8:06 PM
|
||||
#
|
||||
# Copyright 2012, Nutrislice Inc.
|
||||
VERSION = '0.10'
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
# -*- 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()
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue