diff --git a/.gitignore b/.gitignore index b2c78f4..9ca9901 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ labelbase.log labelbase.log.* bgt.log db/ +backup_* *.pyc __pycache__ django/importer/uploadeddata/* diff --git a/BACKUP_AND_MIGRATION_GUIDE.md b/BACKUP_AND_MIGRATION_GUIDE.md new file mode 100644 index 0000000..59b71b8 --- /dev/null +++ b/BACKUP_AND_MIGRATION_GUIDE.md @@ -0,0 +1,896 @@ +# Labelbase Backup and Migration Guide + +A comprehensive guide for safely backing up your Labelbase database and running Django migrations. + +## Table of Contents +- [Why Backup?](#why-backup) +- [Quick Backup](#quick-backup) +- [Automated Backup Script](#automated-backup-script) +- [Running Migrations Safely](#running-migrations-safely) +- [Upgrading Labelbase](#upgrading-labelbase) +- [Restoring from Backup](#restoring-from-backup) +- [Scheduled Backups](#scheduled-backups) +- [Best Practices](#best-practices) + +--- + +## Why Backup? + +**Always backup before running migrations!** Migrations can: +- Alter database table structures in irreversible ways +- Delete data if there are bugs in the migration code +- Fail mid-execution, leaving your database inconsistent +- Introduce conflicts with existing data + +A backup takes 30 seconds. Recovery without one could take hours or days. + +--- + +## Quick Backup + +From your Labelbase directory: + +```bash +# Navigate to Labelbase directory +cd Labelbase + +# Source environment variables +source exports.sh + +# Create backup with timestamp +docker-compose exec -T labelbase_mysql mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" labelbase > backup_$(date +%Y%m%d_%H%M%S).sql +``` + +This creates a backup file like `backup_20250119_143022.sql`. + +### Backup config.ini (Important!) + +The `config.ini` file contains encryption keys and other critical settings. Always back it up too: + +```bash +# Navigate to Labelbase directory +cd Labelbase + +# Source environment variables +source exports.sh + +# Create backup with timestamp +docker-compose exec -T labelbase_django cat /app/config.ini > backup_$(date +%Y%m%d_%H%M%S)_config.ini +``` + +This creates a backup file like `backup_20250119_143022_config.ini`. + +--- + +## Automated Backup Script + +Create a reusable backup script that handles everything automatically. + +### Create the Script + +Save this as `backup-labelbase.sh` in your Labelbase directory: + +```bash +#!/bin/bash + +# Labelbase Database Backup Script +# Usage: ./backup-labelbase.sh + +# Configuration +LABELBASE_DIR="/path/to/Labelbase" # CHANGE THIS to your actual path +BACKUP_DIR="$LABELBASE_DIR/backups" +KEEP_BACKUPS=10 # Number of backups to keep + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Change to Labelbase directory +cd "$LABELBASE_DIR" || exit 1 + +# Source environment variables for MySQL passwords +if [ ! -f "exports.sh" ]; then + echo -e "${RED}✗ Error: exports.sh not found!${NC}" + echo "Make sure you're in the Labelbase directory and exports.sh exists." + exit 1 +fi + +source exports.sh + +# Check if MySQL password is set +if [ -z "$MYSQL_ROOT_PASSWORD" ]; then + echo -e "${RED}✗ Error: MYSQL_ROOT_PASSWORD not set!${NC}" + echo "Make sure exports.sh contains the MySQL password." + exit 1 +fi + +# Create backup directory if it doesn't exist +mkdir -p "$BACKUP_DIR" + +# Generate backup filename with timestamp +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="$BACKUP_DIR/labelbase_backup_$TIMESTAMP.sql" +CONFIG_BACKUP_FILE="$BACKUP_DIR/config_backup_$TIMESTAMP.ini" + +echo -e "${YELLOW}Starting backup...${NC}" +echo "Database backup: $BACKUP_FILE" +echo "Config backup: $CONFIG_BACKUP_FILE" + +# Backup config.ini first (contains encryption keys!) +echo "Backing up config.ini..." +docker-compose exec -T labelbase_django cat /app/config.ini > "$CONFIG_BACKUP_FILE" + +if [ $? -eq 0 ] && [ -s "$CONFIG_BACKUP_FILE" ]; then + echo -e "${GREEN}✓ Config backup successful${NC}" +else + echo -e "${YELLOW}⚠ Config backup failed or file is empty${NC}" +fi + +# Create database backup +docker-compose exec -T labelbase_mysql mysqldump \ + -u root \ + -p"${MYSQL_ROOT_PASSWORD}" \ + --single-transaction \ + --quick \ + --lock-tables=false \ + labelbase > "$BACKUP_FILE" + +# Check if backup was successful +if [ $? -eq 0 ] && [ -s "$BACKUP_FILE" ]; then + echo -e "${GREEN}✓ Backup successful!${NC}" + + # Get file size + SIZE=$(du -h "$BACKUP_FILE" | cut -f1) + echo "Backup size: $SIZE" + + # Compress backup to save space + echo "Compressing backup..." + gzip "$BACKUP_FILE" + + if [ $? -eq 0 ]; then + COMPRESSED_SIZE=$(du -h "${BACKUP_FILE}.gz" | cut -f1) + echo -e "${GREEN}✓ Compressed to: $COMPRESSED_SIZE${NC}" + echo "Backup location: ${BACKUP_FILE}.gz" + else + echo -e "${YELLOW}⚠ Compression failed, keeping uncompressed backup${NC}" + fi + + # Clean up old backups (keep only last N backups) + echo "Cleaning up old backups (keeping last $KEEP_BACKUPS)..." + BACKUP_COUNT=$(ls -1 "$BACKUP_DIR"/labelbase_backup_*.sql.gz 2>/dev/null | wc -l) + + if [ "$BACKUP_COUNT" -gt "$KEEP_BACKUPS" ]; then + ls -t "$BACKUP_DIR"/labelbase_backup_*.sql.gz | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm + echo -e "${GREEN}✓ Cleaned up old backups${NC}" + else + echo "No cleanup needed ($BACKUP_COUNT backups exist)" + fi + + echo "" + echo -e "${GREEN}=== Backup Complete ===${NC}" + echo "Database: ${BACKUP_FILE}.gz" + echo "Config: ${CONFIG_BACKUP_FILE}" + +else + echo -e "${RED}✗ Backup failed!${NC}" + + # Remove empty or failed backup file + [ -f "$BACKUP_FILE" ] && rm "$BACKUP_FILE" + + echo "Troubleshooting:" + echo "1. Check if MySQL container is running: docker-compose ps" + echo "2. Check MySQL logs: docker-compose logs labelbase_mysql" + echo "3. Verify password in exports.sh" + exit 1 +fi +``` + +### Make Script Executable + +```bash +chmod +x backup-labelbase.sh +``` + +### Edit Configuration + +Open `backup-labelbase.sh` and change this line to your actual Labelbase path: + +```bash +LABELBASE_DIR="/path/to/Labelbase" # CHANGE THIS! +``` + +For example: +```bash +LABELBASE_DIR="/root/Labelbase" +# or +LABELBASE_DIR="/home/username/Labelbase" +``` + +### Run the Backup + +```bash +./backup-labelbase.sh +``` + +You'll see output like: +``` +Starting backup... +Backup file: /path/to/Labelbase/backups/labelbase_backup_20250119_143022.sql +✓ Backup successful! +Backup size: 15M +Compressing backup... +✓ Compressed to: 3.2M +Backup location: /path/to/Labelbase/backups/labelbase_backup_20250119_143022.sql.gz +Cleaning up old backups (keeping last 10)... +No cleanup needed (3 backups exist) + +=== Backup Complete === +Location: /path/to/Labelbase/backups/labelbase_backup_20250119_143022.sql.gz +``` + +--- + +## Running Migrations Safely + +**Always follow this order:** + +### Step 1: Create a Backup + +```bash +./backup-labelbase.sh +``` + +### Step 2: Check Migration Status + +```bash +source exports.sh +docker-compose exec labelbase_django python manage.py showmigrations +``` + +This shows which migrations are applied (marked with `[X]`) and pending (marked with `[ ]`). + +### Step 3: Review Pending Migrations + +Look for any unapplied migrations. If you see pending migrations for critical apps, review them carefully. + +### Step 4: Apply Migrations + +```bash +# If you've modified models, create new migrations first +docker-compose exec labelbase_django python manage.py makemigrations + +# Apply all pending migrations +docker-compose exec labelbase_django python manage.py migrate +``` + +### Step 5: Verify Application + +After migrations complete: +1. Check for any error messages +2. Visit your Labelbase site +3. Test critical functionality +4. Check Django logs: `docker-compose logs labelbase_django` + +### Step 6: If Something Goes Wrong + +If migrations fail or break functionality, restore from backup (see below). + +--- + +## Upgrading Labelbase + +When new versions of Labelbase are released, follow this workflow to safely upgrade. + +### Complete Upgrade Workflow + +**Step 1: Backup First (Critical!)** + +```bash +cd Labelbase +./backup-labelbase.sh +``` + +**Step 2: Pull Latest Code** + +```bash +git pull origin master +``` + +**Step 3: Rebuild Containers (if needed)** + +If dependencies or Docker configuration changed: + +```bash +source exports.sh && docker-compose up --build -d +``` + +Or use the main script: + +```bash +source exports.sh && ./build-and-run-labelbase.sh +``` + +**⚠️ IMPORTANT**: These commands are SAFE - they rebuild containers but preserve your data in Docker volumes. Your database and uploaded files are NOT deleted. + +**❌ DANGER ZONE - Commands that DELETE data:** +```bash +# NEVER run these unless you want to lose ALL data: +docker-compose down -v # The -v flag deletes volumes = data loss! +docker volume prune # Deletes unused volumes +docker system prune -a # Nuclear option - deletes everything +``` + +**Step 4: Apply Migrations and collect static files ** + +```bash +source exports.sh +docker-compose exec labelbase_django python manage.py showmigrations +docker-compose exec labelbase_django python manage.py migrate +docker-compose exec labelbase_django python manage.py collectstatic --noinput + +``` + +**Step 5: Restart Services** + +```bash +docker-compose restart labelbase_django +``` + +**Step 6: Verify Everything Works** + +1. Visit your Labelbase site +2. Test critical functionality +3. Check logs: `docker-compose logs -f labelbase_django` + +### Quick Upgrade Script + +Create `update-and-migrate.sh` for a streamlined upgrade process: + +```bash +#!/bin/bash + +# Labelbase Quick Update & Migration Script +# Usage: source exports.sh && ./update-and-migrate.sh + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${YELLOW}=== Labelbase Update & Migrate ===${NC}" + +# Check if we're in the right directory +if [ ! -f "docker-compose.yml" ]; then + echo -e "${RED}Error: Not in Labelbase directory (docker-compose.yml not found)${NC}" + exit 1 +fi + +# Check env vars +if [[ -z "${MYSQL_ROOT_PASSWORD}" ]]; then + echo -e "${RED}Error: Run 'source exports.sh' first!${NC}" + exit 1 +fi + +# Step 1: Backup +echo -e "${YELLOW}Step 1: Creating backup...${NC}" +if [ -f "backup-labelbase.sh" ]; then + ./backup-labelbase.sh + if [ $? -ne 0 ]; then + echo -e "${RED}Backup failed! Aborting upgrade.${NC}" + exit 1 + fi +else + echo -e "${YELLOW}⚠ Warning: backup-labelbase.sh not found, skipping backup${NC}" + read -p "Continue without backup? (yes/no): " -r + if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then + echo "Upgrade cancelled." + exit 0 + fi +fi + +# Step 2: Pull latest code +echo "" +echo -e "${YELLOW}Step 2: Pulling latest changes...${NC}" +git pull origin master + +if [ $? -ne 0 ]; then + echo -e "${RED}Git pull failed!${NC}" + exit 1 +fi + +# Step 3: Check for pending migrations +echo "" +echo -e "${YELLOW}Step 3: Checking for migrations...${NC}" +PENDING=$(docker-compose exec -T labelbase_django python manage.py showmigrations --plan 2>/dev/null | grep "\[ \]" | wc -l) + +if [ $PENDING -gt 0 ]; then + echo -e "${YELLOW}Found $PENDING pending migration(s)${NC}" + + # Show what will be migrated + echo "Pending migrations:" + docker-compose exec -T labelbase_django python manage.py showmigrations | grep "\[ \]" + + echo "" + read -p "Apply migrations now? (y/n): " -n 1 -r + echo + + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "Applying migrations..." + docker-compose exec -T labelbase_django python manage.py migrate --noinput + + if [ $? -eq 0 ]; then + echo -e "${GREEN}✓ Migrations applied successfully${NC}" + else + echo -e "${RED}✗ Migrations failed!${NC}" + echo "Check logs: docker-compose logs labelbase_django" + exit 1 + fi + else + echo -e "${YELLOW}⚠ Skipping migrations${NC}" + echo "Run manually later: docker-compose exec labelbase_django python manage.py migrate" + fi +else + echo -e "${GREEN}✓ No pending migrations${NC}" +fi + +# Step 4: Restart Django +echo "" +echo -e "${YELLOW}Step 4: Restarting Django...${NC}" +docker-compose restart labelbase_django + +echo "" +echo -e "${GREEN}=== Update Complete! ===${NC}" +echo "Check logs: docker-compose logs -f labelbase_django" +echo "Visit your site to verify everything works" +``` + +Make it executable: + +```bash +chmod +x update-and-migrate.sh +``` + +### Using the Quick Upgrade Script + +```bash +# Navigate to Labelbase +cd Labelbase + +# Source environment and run update +source exports.sh && ./update-and-migrate.sh +``` + +The script will: +1. ✓ Create automatic backup +2. ✓ Pull latest code from git +3. ✓ Detect pending migrations +4. ✓ Ask for confirmation before applying +5. ✓ Restart services +6. ✓ Provide verification steps + +### When to Rebuild vs. Restart + +**Just restart** (`docker-compose restart`) when: +- Only Django code changed (Python files) +- No dependency updates +- No Dockerfile changes +- Fastest option + +**Full rebuild** (`docker-compose up --build -d`) when: +- requirements.txt changed +- Dockerfile modified +- New system packages needed +- Docker configuration changed + +**Data Safety Note**: Both `restart` and `--build` are SAFE - they preserve your data. Docker stores your database and files in **volumes** that persist across rebuilds. + +If unsure, rebuild - it's safer and only takes a minute longer. + +### What Actually Deletes Data + +Only these commands delete data (requires `-v` flag): + +```bash +# DANGER: This deletes ALL data including database! +docker-compose down -v + +# To safely stop without deleting data, use: +docker-compose down # Safe - keeps volumes +docker-compose stop # Safe - just stops containers +``` + +**Rule of thumb**: If you see `-v` flag, your data is at risk! + +### Rollback After Failed Upgrade + +If something goes wrong: + +```bash +# 1. Stop services +docker-compose down + +# 2. Restore previous code +git reset --hard HEAD~1 + +# 3. Restore database +./restore-labelbase.sh backups/labelbase_backup_TIMESTAMP.sql.gz + +# 4. Restart +source exports.sh && docker-compose up -d +``` + +--- + +## Restoring from Backup + +If something goes wrong, you can restore your database from a backup. + +### Quick Restore + +```bash +# Navigate to Labelbase directory +cd Labelbase + +# Source environment variables +source exports.sh + +# Stop Django to prevent conflicts +docker-compose stop labelbase_django + +# Decompress and restore backup +gunzip -c backups/labelbase_backup_20250119_143022.sql.gz | \ + docker-compose exec -T labelbase_mysql mysql -u root -p"${MYSQL_ROOT_PASSWORD}" labelbase + +# Restart all services +docker-compose up -d + +# Check logs +docker-compose logs -f labelbase_django +``` + +### Restore Script (Optional) + +Create `restore-labelbase.sh`: + +```bash +#!/bin/bash + +# Labelbase Database Restore Script +# Usage: ./restore-labelbase.sh + +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 " + 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!** diff --git a/BARE_METALL_INSTALL.md b/BARE_METALL_INSTALL.md new file mode 100644 index 0000000..ddc2c0c --- /dev/null +++ b/BARE_METALL_INSTALL.md @@ -0,0 +1,357 @@ +# Labelbase Bare Metal Installation Guide + +This guide will help you install and run Labelbase directly on macOS and Linux systems, based on the RaspiBlitz installation script. + +## Prerequisites + +### Common Requirements +- Git +- Python 3.8 or higher +- pip (Python package manager) +- virtualenv +- MySQL/MariaDB server + +### System-Specific Requirements + +#### macOS +```bash +# Install Homebrew if not already installed +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# Install required packages +brew install python3 mysql git +brew install pkg-config mysql-client +``` + +#### Linux (Ubuntu/Debian) +```bash +# Update package list +sudo apt update + +# Install required packages +sudo apt install -y \ + mariadb-server \ + mariadb-client \ + default-libmysqlclient-dev \ + build-essential \ + python3 \ + python3-pip \ + python3-venv \ + git \ + libpcre3-dev +``` + +#### Linux (CentOS/RHEL/Fedora) +```bash +# For CentOS/RHEL 8+ +sudo dnf install -y \ + mariadb-server \ + mariadb-devel \ + python3 \ + python3-pip \ + python3-virtualenv \ + git \ + gcc \ + gcc-c++ \ + make + +# For older versions, use yum instead of dnf +``` + +## Installation Steps + +### 1. Create Application User (Optional but Recommended) + +#### macOS +```bash +# Create a new user (optional on macOS for development) +sudo dscl . -create /Users/labelbase +sudo dscl . -create /Users/labelbase UserShell /bin/bash +sudo dscl . -create /Users/labelbase RealName "Labelbase User" +sudo dscl . -create /Users/labelbase UniqueID 1001 +sudo dscl . -create /Users/labelbase PrimaryGroupID 20 +sudo dscl . -create /Users/labelbase NFSHomeDirectory /Users/labelbase +sudo createhomedir -c -u labelbase +``` + +#### Linux +```bash +# Create system user +sudo adduser --system --group --shell /bin/bash --home /home/labelbase labelbase +sudo -u labelbase cp -r /etc/skel/. /home/labelbase/ +``` + +### 2. Start Database Service + +#### macOS +```bash +# Start MySQL service +brew services start mysql + +# Secure the installation +mysql_secure_installation +``` + +#### Linux +```bash +# Start MariaDB service +sudo systemctl enable mariadb +sudo systemctl start mariadb + +# Secure the installation +sudo mysql_secure_installation +``` + +### 3. Download and Setup Labelbase + +```bash +# Switch to labelbase user (if created) or use your regular user +# sudo su - labelbase # (if using dedicated user) + +# Set variables +LABELBASE_HOME="$HOME" # or /home/labelbase if using dedicated user +LABELBASE_REPO="https://github.com/Labelbase/Labelbase/" +LABELBASE_VERSION="2.2.1" + +# Clone the repository +git clone $LABELBASE_REPO $LABELBASE_HOME/labelbase +cd $LABELBASE_HOME/labelbase + +# Checkout specific version +git checkout $LABELBASE_VERSION + +# Create virtual environment +python3 -m venv $LABELBASE_HOME/ENV + +# Activate virtual environment +source $LABELBASE_HOME/ENV/bin/activate + +# Install Python dependencies +pip install --upgrade pip +pip install --no-cache-dir -r $LABELBASE_HOME/labelbase/django/requirements.txt +``` + +### 4. Database Configuration + +```bash +# Generate a secure password +MYSQL_PASSWORD=$(openssl rand -base64 32 | tr -d '+/' | fold -w 32 | head -n 1) + +# Create exports file +cat > $LABELBASE_HOME/exports.sh << EOF +export MYSQL_PASSWORD=$MYSQL_PASSWORD +export DATABASE_URL=mysql://ulabelbase:$MYSQL_PASSWORD@localhost:3306/labelbase +EOF + +chmod 755 $LABELBASE_HOME/exports.sh +``` + +#### Create Database and User + +##### macOS +```bash +# Connect to MySQL +mysql -u root -p + +# In MySQL prompt: +CREATE DATABASE labelbase; +CREATE USER 'ulabelbase'@'localhost' IDENTIFIED BY 'YOUR_GENERATED_PASSWORD'; +GRANT ALL PRIVILEGES ON labelbase.* TO 'ulabelbase'@'localhost'; +FLUSH PRIVILEGES; +EXIT; +``` + +##### Linux +```bash +# Connect to MariaDB +sudo mysql + +# In MariaDB prompt: +CREATE DATABASE labelbase; +CREATE USER 'ulabelbase'@'localhost' IDENTIFIED BY 'YOUR_GENERATED_PASSWORD'; +GRANT ALL PRIVILEGES ON labelbase.* TO 'ulabelbase'@'localhost'; +FLUSH PRIVILEGES; +EXIT; +``` + +**Note:** Replace `YOUR_GENERATED_PASSWORD` with the password generated in the previous step. + +### 5. Django Setup + +```bash +# Navigate to Django directory +cd $LABELBASE_HOME/labelbase/django + +# Activate virtual environment and load environment variables +source $LABELBASE_HOME/ENV/bin/activate +source $LABELBASE_HOME/exports.sh + +# Run Django migrations +python manage.py makemigrations --noinput +python manage.py migrate --noinput +python manage.py collectstatic --noinput + +# Create a superuser (optional) +python manage.py createsuperuser +``` + +### 6. Running Labelbase + +#### Development Mode +```bash +# Activate environment +source $LABELBASE_HOME/ENV/bin/activate +source $LABELBASE_HOME/exports.sh + +# Navigate to Django directory +cd $LABELBASE_HOME/labelbase/django + +# Run development server +python manage.py runserver 0.0.0.0:8089 + +# Access at: http://localhost:8089 +``` + +#### Production Mode (using Gunicorn) +```bash +# Install Gunicorn if not already installed +pip install gunicorn + +# Run with Gunicorn +source $LABELBASE_HOME/ENV/bin/activate +source $LABELBASE_HOME/exports.sh +cd $LABELBASE_HOME/labelbase/django + +gunicorn labellabor.wsgi:application -b 0.0.0.0:8089 --reload +``` + +## Creating System Services (Optional) + +### macOS (using LaunchAgent) + +Create a plist file at `~/Library/LaunchAgents/com.labelbase.app.plist`: + +```xml + + + + + Label + com.labelbase.app + ProgramArguments + + /bin/bash + -c + 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 + + RunAtLoad + + KeepAlive + + StandardOutPath + /Users/labelbase/labelbase.out + StandardErrorPath + /Users/labelbase/labelbase.err + + +``` + +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. diff --git a/DEVELOPMENT_GUIDE.md b/DEVELOPMENT_GUIDE.md new file mode 100644 index 0000000..18baa1a --- /dev/null +++ b/DEVELOPMENT_GUIDE.md @@ -0,0 +1,312 @@ +# Labelbase Development Guide + +Quick reference for working with and developing Labelbase in Docker. + +## Initial Setup + +### 1. Clone and Setup +```bash +git clone https://github.com/Labelbase/Labelbase/ +cd Labelbase +``` + +### 2. Generate MySQL Passwords +```bash +./make-exports.sh +``` + +This creates `exports.sh` with random passwords. **Backup this file!** + +### 3. Build and Run +```bash +source exports.sh && ./build-and-run-labelbase.sh +``` + +The `source exports.sh` loads the passwords into your shell, then the script uses them. + +Access at: http://127.0.0.1:8080 + +--- + +## Daily Development Workflow + +### Start/Stop Services +```bash +# Start (always source exports.sh first!) +source exports.sh && docker-compose up -d + +# Stop +docker-compose down + +# Rebuild and restart (after code changes) +source exports.sh && docker-compose up --build -d + +# Or use the main script +source exports.sh && ./build-and-run-labelbase.sh +``` + +### View Logs +```bash +# All services +docker-compose logs -f + +# Specific service +docker-compose logs -f labelbase_django +docker-compose logs -f labelbase_mysql +docker-compose logs -f labelbase_nginx + +# Search logs +docker-compose logs labelbase_django | grep -i error +``` + +### Access Container Shell +```bash +# Django container (most common) +docker-compose exec labelbase_django bash + +# MySQL container +docker-compose exec labelbase_mysql bash + +# Nginx container +docker-compose exec labelbase_nginx sh +``` + +### Django Management Commands +```bash +# From host +docker-compose exec labelbase_django python manage.py + +# 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` diff --git a/build-and-run-labelbase.sh b/build-and-run-labelbase.sh index bfa4ec3..b00af70 100755 --- a/build-and-run-labelbase.sh +++ b/build-and-run-labelbase.sh @@ -7,17 +7,19 @@ 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" diff --git a/django/Dockerfile b/django/Dockerfile index cb3226f..5b90b98 100644 --- a/django/Dockerfile +++ b/django/Dockerfile @@ -11,7 +11,7 @@ RUN apt-get update && \ default-libmysqlclient-dev \ build-essential \ cron vim logrotate \ - libpcre3-dev \ + libpcre2-dev \ default-mysql-client \ && rm -rf /var/lib/apt/lists/* \ && pip install --upgrade pip \ diff --git a/django/attachments/django-attachments-1.11.zip b/django/attachments/django-attachments-1.11.zip deleted file mode 100644 index ea2e0b9..0000000 Binary files a/django/attachments/django-attachments-1.11.zip and /dev/null differ diff --git a/django/attachments/models.py b/django/attachments/models.py index 01da2d4..2f107cf 100644 --- a/django/attachments/models.py +++ b/django/attachments/models.py @@ -21,7 +21,6 @@ def attachment_upload(instance, 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) diff --git a/django/attachments/templatetags/attachments_tags.py b/django/attachments/templatetags/attachments_tags.py index e5a8633..6580fef 100644 --- a/django/attachments/templatetags/attachments_tags.py +++ b/django/attachments/templatetags/attachments_tags.py @@ -22,38 +22,24 @@ def attachment_form(context, obj, **kwargs): The user must own ``attachments.add_attachment permission`` to add attachments. """ - 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} + + return { + "form": AttachmentForm(), + "form_url": add_url_for_obj(obj), + "next": context.request.path, + } @register.inclusion_tag("attachments/delete_link.html", takes_context=True) def attachment_delete_link(context, attachment, **kwargs): - """ - 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") - ): + if context["user"] == attachment.creator: return { "next": context.request.path, "delete_url": reverse( "attachments:delete", kwargs={"attachment_pk": attachment.pk} ), } - return {"delete_url": None} + @register.simple_tag diff --git a/django/attachments/views.py b/django/attachments/views.py index 77f7ddb..c6938eb 100644 --- a/django/attachments/views.py +++ b/django/attachments/views.py @@ -49,10 +49,6 @@ 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 @@ -71,17 +67,13 @@ 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.has_perm("attachments.delete_attachment") - and request.user == g.creator - ) or request.user.has_perm("attachments.delete_foreign_attachments"): + if request.user == g.creator: remove_file_from_disk(g.attachment_file) g.delete() messages.success(request, gettext("Your attachment was deleted.")) diff --git a/django/background_task/tasks.py b/django/background_task/tasks.py index 80a1248..409cea6 100644 --- a/django/background_task/tasks.py +++ b/django/background_task/tasks.py @@ -38,7 +38,6 @@ def bg_runner(proxy_task, task=None, loop=None, *args, **kwargs): task = task_qs[0] if func is None: raise BackgroundTaskError("Function is None, can't execute!") - print("bg_runner, loop {}".format(loop)) kwargs['loop'] = loop func(*args, **kwargs) @@ -96,8 +95,6 @@ class Tasks(object): return _decorator def run_task(self, task_name, loop, args=None, kwargs=None): - print("run_task loop {}".format(loop)) - # task_name can be either the name of a task or a Task instance. if isinstance(task_name, Task): task = task_name task_name = task.task_name diff --git a/django/background_task/templatetags/backgroundtask_tags.py b/django/background_task/templatetags/backgroundtask_tags.py index 267f055..1b7249d 100644 --- a/django/background_task/templatetags/backgroundtask_tags.py +++ b/django/background_task/templatetags/backgroundtask_tags.py @@ -5,5 +5,8 @@ register = template.Library() @register.simple_tag def is_label_id_in_queue(label_id): - return Task.objects.filter(task_name="finances.tasks.check_spent", - task_params__contains=label_id).exists() + try: + return Task.objects.filter(task_name="finances.tasks.check_spent", + task_params__contains=label_id).exists() + except: + return False diff --git a/django/finances/admin.py b/django/finances/admin.py index 766fba6..ba6f402 100644 --- a/django/finances/admin.py +++ b/django/finances/admin.py @@ -2,7 +2,16 @@ from django.contrib import admin from .models import OutputStat, HistoricalPrice class OutputStatAdmin(admin.ModelAdmin): - list_display = ('type_ref_hash', 'value', 'confirmed_at_block_height', 'confirmed_at_block_time', 'get_spent_status', 'spent', 'network', 'user') + list_display = ('type_ref_hash', + 'value', + 'confirmed_at_block_height', + 'confirmed_at_block_time', + 'get_spent_status', + 'spent', + 'network', + 'user', + 'next_enc_input_attrs', + 'last_error') list_filter = ('network', 'spent') search_fields = ('type_ref_hash',) ordering = ('-confirmed_at_block_time',) diff --git a/django/finances/electrum.py b/django/finances/electrum.py index bb8459b..2c8c04f 100644 --- a/django/finances/electrum.py +++ b/django/finances/electrum.py @@ -1,36 +1,32 @@ from connectrum.client import StratumClient from connectrum.svr_info import ServerInfo from connectrum import ElectrumErrorResponse - - from labelbase.models import Label from finances.models import OutputStat, HistoricalPrice - import logging -logger = logging.getLogger('labelbase') +logger = logging.getLogger('labelbase') async def interact(conn, server_info, method, utxo): try: await conn.connect(server_info, "s", use_tor=server_info.is_onion, - disable_cert_verify=True, short_term=True) + disable_cert_verify=True, short_term=True) txid, index = utxo.split(":") try: txn = await conn.RPC(method, txid, True) if txn: try: blocktime = int(txn.get('blocktime', 0)) - logger.debug("blocktime: {}".format(blocktime)) + logger.debug(f"blocktime: {blocktime}") except Exception as ex: blocktime = 0 - logger.error("Can't get blocktime: {}".format(ex)) + logger.error(f"Can't get blocktime: {ex}") utxo = txn.get('vout')[int(index)] address = txn.get('vout')[int(index)].get('scriptPubKey', {}).get('address') - value = txn.get('vout')[int(index)].get('value')*100000000 - return (txid, index, address, value, blocktime, utxo) + value = txn.get('vout')[int(index)].get('value') * 100000000 + return txid, index, address, value, blocktime, utxo except ElectrumErrorResponse as ex: - logger.error("ERROR: {} {}".format(ex, conn.last_error)) - + logger.error(f"ERROR: {ex} {conn.last_error}") finally: conn.close() @@ -38,198 +34,117 @@ async def interact(conn, server_info, method, utxo): async def interact_addr(conn, server_info, method, addr): try: await conn.connect(server_info, "s", use_tor=server_info.is_onion, - disable_cert_verify=True, short_term=True) + disable_cert_verify=True, short_term=True) try: hextx = await conn.RPC(method, addr) if hextx is not None: - print(hextx) return hextx - else: - print("Failed to fetch transaction.") except ElectrumErrorResponse as ex: - print(ex) + logger.error(ex) finally: conn.close() + def is_valid_output_ref(ref): - if not ref: - return False - if ":" in ref: - return True - return False + return ":" in ref if ref else False + def checkup_label(label_id, loop): - if label_id and loop: - try: - elem = Label.objects.get(id=label_id) - output = OutputStat.objects.filter(user=elem.labelbase.user, - type_ref_hash=elem.type_ref_hash, - network=elem.labelbase.network).last() - if not output: - output = OutputStat(user=elem.labelbase.user, - type_ref_hash=elem.type_ref_hash, - network=elem.labelbase.network, value=0) + if not label_id or not loop: + logger.error(f"Invalid input: label_id={label_id}, loop={loop}") + return - if elem.type == "output" and is_valid_output_ref(elem.ref) and \ - (output.spent is not True or output.confirmed_at_block_time == 0): - if elem.labelbase.is_mainnet: - electrum_hostname = elem.labelbase.user.profile.electrum_hostname or "electrum.emzy.de" - electrum_ports = elem.labelbase.user.profile.electrum_ports or "s50002" - elif elem.labelbase.is_testnet: - electrum_hostname = elem.labelbase.user.profile.electrum_hostname_test or "testnet.qtornado.com" - electrum_ports = elem.labelbase.user.profile.electrum_ports_test or "s51002" - server_info = ServerInfo(electrum_hostname, electrum_hostname, ports=(electrum_ports)) - - conn = StratumClient() - utxo = elem.ref - utxo_data = {} - utxo_resp = loop.run_until_complete(interact(conn, server_info, "blockchain.transaction.get", utxo)) - - if utxo_resp: - txid, index, address, value, blocktime, utxo_data = utxo_resp - if utxo_data: - output.next_input_attributes = utxo_data - if blocktime: - HistoricalPrice.get_or_create_from_api(timestamp=blocktime) - - try: - unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.address.listunspent", address)) - except: - conn.last_error = None # reset error if needed - unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.scripthash.listunspent", address)) - - - - - utxo_value = 0 - utxo_height = 0 - - if unspents: - for unspent in unspents: - if unspent.get('tx_hash') == txid and \ - unspent.get('tx_pos') == int(index) and \ - unspent.get('height') > 0 and \ - unspent.get('value') > 0: # Output is confirmed, but not spent yet - output.spent = False - utxo_value = unspent.get('value') - utxo_height = unspent.get('height') - - output.network = elem.labelbase.network - if utxo_height: - output.confirmed_at_block_height = utxo_height - if blocktime: - output.confirmed_at_block_time = blocktime - if utxo_value: - output.value = utxo_value - elif value: - output.value = value - break - # - elif conn.last_error: - output.last_error = conn.last_error - else: - output.last_error = {} - output.save() - try: - conn.close() - except: - pass - - except Exception as e: - logger.error("Error processing label {}: {}".format(label_id, e)) - else: - if not label_id: - logger.error("Can't get label_id! {}".format(label_id)) - if not loop: - logger.error("Can't get loop!") - - -def checkup_label_buggy(label_id, loop): - if label_id and loop: + try: elem = Label.objects.get(id=label_id) - output = OutputStat.objects.filter(user=elem.labelbase.user, - type_ref_hash=elem.type_ref_hash, - network=elem.labelbase.network).last() + output = OutputStat.objects.filter( + user=elem.labelbase.user, + type_ref_hash=elem.type_ref_hash, + network=elem.labelbase.network + ).last() + if not output: - print("Creating OutputStat") - output = OutputStat(user=elem.labelbase.user, - type_ref_hash=elem.type_ref_hash, - network=elem.labelbase.network, value=0) - print("Using OutputStat id {}".format(output)) - print("elem.type {} {} {} {}".format(elem.type, is_valid_output_ref(elem.ref), elem.ref, output.spent)) - if elem.type == "output" and is_valid_output_ref(elem.ref) and \ - (output.spent is not True or output.confirmed_at_block_time == 0): - electrum_hostname = elem.labelbase.user.profile.electrum_hostname - if not electrum_hostname: - electrum_hostname = "electrum.emzy.de" - electrum_ports = elem.labelbase.user.profile.electrum_ports - if not electrum_ports: - electrum_ports = "s50002" - print("going for server_info") - server_info = ServerInfo(electrum_hostname, electrum_hostname, ports=((electrum_ports))) - print("server_info: {}".format(server_info)) + output = OutputStat( + user=elem.labelbase.user, + type_ref_hash=elem.type_ref_hash, + network=elem.labelbase.network, + value=0, + spent=None, + confirmed_at_block_height=0, + confirmed_at_block_time=0 + ) + output.save() + logger.debug(f"Output before processing: {output.output_metrics_dict()}") + + if elem.type == "output" and is_valid_output_ref(elem.ref) and ( + output.spent is not True or output.confirmed_at_block_time is None + ): + # Determine server info based on network + if elem.labelbase.is_mainnet: + electrum_hostname = elem.labelbase.user.profile.electrum_hostname or "fulcrum.sethforprivacy.com" + electrum_ports = elem.labelbase.user.profile.electrum_ports or "s50002" + elif elem.labelbase.is_testnet: + electrum_hostname = elem.labelbase.user.profile.electrum_hostname_test or "testnet.qtornado.com" + electrum_ports = elem.labelbase.user.profile.electrum_ports_test or "s51002" + else: + raise ValueError("Unknown network type.") + + server_info = ServerInfo(electrum_hostname, electrum_hostname, ports=(electrum_ports)) conn = StratumClient() - assert elem.type_ref_hash utxo = elem.ref - tx_hash, tx_pos = elem.ref.split(":") + + # Fetch transaction details utxo_resp = loop.run_until_complete(interact(conn, server_info, "blockchain.transaction.get", utxo)) - blocktime = 0 + if utxo_resp: - print("utxo_resp {}".format(utxo_resp)) - txid, index, address, value, blocktime = utxo_resp + txid, index, address, value, blocktime, utxo_data = utxo_resp + logger.debug(f"Transaction {txid} fetched with blocktime {blocktime}") if blocktime: - print("Found blocktime {} for label id {}.".format(blocktime, label_id)) - HistoricalPrice.get_or_create_from_api(timestamp=blocktime) + output.confirmed_at_block_time = blocktime + HistoricalPrice.get_or_create_from_api(None, timestamp=blocktime) + + # Fetch all unspents for the address try: unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.address.listunspent", address)) except: - conn.last_error = None # reset error if needed + conn.last_error = None unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.scripthash.listunspent", address)) - unspent_utxo = False - utxo_value = 0 - utxo_height = 0 - print("unspents: {}".format(unspents)) + logger.debug(f"Unspents for address {address}: {unspents}") - if unspents: - for unspent in unspents: - if unspent.get('tx_hash') == tx_hash and \ - unspent.get('tx_pos') == int(tx_pos) and \ - unspent.get('height') > 0 and \ - unspent.get('value') > 0: - unspent_utxo = True - utxo_value = unspent.get('value') - utxo_height = unspent.get('height') - print("found unspent: {}".format(unspent)) - break - if output: - output.network = elem.labelbase.network - if utxo_height: - output.confirmed_at_block_height = utxo_height - if blocktime: - output.confirmed_at_block_time = blocktime - if utxo_value: - output.value = utxo_value - elif value: # take value from TX - output.value = value - if unspent_utxo: + utxo_found = False + for unspent in unspents: + if unspent.get('tx_hash') == txid and unspent.get('tx_pos') == int(index): output.spent = False - else: - output.spent = True - output.last_error = {} + output.value = unspent.get('value', None) + output.confirmed_at_block_height = unspent.get('height', None) + utxo_found = True + + # Ensure all key details are stored + output.network = elem.labelbase.network + if unspent.get('height'): + output.confirmed_at_block_height = unspent.get('height') + if blocktime: + output.confirmed_at_block_time = blocktime + if unspent.get('value') is not None: + output.value = unspent.get('value') + elif value: + output.value = value + break + + if not utxo_found: + output.spent = True + logger.warning(f"UTXO {txid}:{index} not found in unspent outputs.") + elif conn.last_error: - # Damn... output.last_error = conn.last_error else: - output.last_error = {} + logger.warning(f"Unknown error occurred for UTXO {utxo}") + output.last_error = {"error": "Unknown issue"} + + logger.debug(f"Output after processing (before save): {output.output_metrics_dict()}") output.save() - print("output id {} saved".format(output.id)) - try: - conn.close() - except: - pass - else: - if not label_id: - logger.error("Can't get label_id! {}".format(label_id)) - if not loop: - logger.error("Can't get loop!") + output.refresh_from_db() + logger.debug(f"Output after saving: {output.output_metrics_dict()}") + + except Exception as e: + logger.error(f"Error processing label {label_id}: {e}") diff --git a/django/finances/migrations/0012_auto_20240701_0932.py b/django/finances/migrations/0012_auto_20240701_0932.py new file mode 100644 index 0000000..f357d8c --- /dev/null +++ b/django/finances/migrations/0012_auto_20240701_0932.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.25 on 2024-07-01 09:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finances', '0011_alter_outputstat_network'), + ] + + operations = [ + migrations.RemoveField( + model_name='outputstat', + name='next_input_attributes', + ), + migrations.AddField( + model_name='outputstat', + name='next_enc_input_attrs', + field=models.TextField(default=None, null=True), + ), + ] diff --git a/django/finances/models.py b/django/finances/models.py index 5ba8e2c..b123cb2 100644 --- a/django/finances/models.py +++ b/django/finances/models.py @@ -1,5 +1,6 @@ import requests from django.db import models +from django.contrib import messages from djmoney.models.fields import MoneyField from decimal import Decimal import datetime @@ -7,9 +8,9 @@ from pymempool import MempoolAPI from labelbase.receivers import compute_type_ref_hash from django.conf import settings from jsonfield import JSONField +import json from django.contrib.auth.models import User - - +from shared.encryption import get_fernet_key, cipher_suite import logging logger = logging.getLogger('labelbase') @@ -42,7 +43,20 @@ class OutputStat(models.Model): confirmed_at_block_time = models.IntegerField(default=0) last_error = JSONField(default={}) - next_input_attributes = JSONField(default={}) # will be used for fee estimation + next_enc_input_attrs = models.TextField(default=None, null=True) # will be used for fee estimation + + def set_next_input_attributes(self, data): + json_data = json.dumps(data).encode('utf-8') + encrypted_data = cipher_suite.encrypt(json_data) + self.next_enc_input_attrs = encrypted_data.decode('utf-8') + + def next_input_attributes(self): + if self.next_enc_input_attrs: + encrypted_data = self.next_enc_input_attrs.encode('utf-8') + decrypted_data = cipher_suite.decrypt(encrypted_data) + return json.loads(decrypted_data.decode('utf-8')) + return json.loads("{}") + MAINNET = 'mainnet' TESTNET = 'testnet' @@ -63,12 +77,12 @@ class OutputStat(models.Model): @property def get_spent_status(self): - if self.confirmed_at_block_time == 0: - return "unconfirmed" if self.spent: return "spent" - if not self.spent: + elif not self.spent: return "unspent" + elif self.confirmed_at_block_time == 0: + return "unconfirmed" def output_metrics_dict(self, tracked_fiat_value=0, fiat_currency="USD"): """ @@ -88,8 +102,8 @@ class OutputStat(models.Model): # Check if the block time is confirmed if self.confirmed_at_block_time: # Get or create HistoricalPrice instance for the confirmed block time - obj, created = HistoricalPrice.get_or_create_from_api( - timestamp=self.confirmed_at_block_time + obj, created = HistoricalPrice.get_or_create_from_api(self.user, + timestamp=self.confirmed_at_block_time ) if obj is None: logger.error("No price info found for {}".format(self.confirmed_at_block_time)) @@ -112,8 +126,8 @@ class OutputStat(models.Model): timestamp = int(current_datetime.timestamp()) # Get or create HistoricalPrice instance for the current time in UTC - obj_now, created = HistoricalPrice.get_or_create_from_api( - timestamp=timestamp + obj_now, created = HistoricalPrice.get_or_create_from_api(self.user, + timestamp=timestamp ) # Calculate the current price @@ -144,7 +158,7 @@ class OutputStat(models.Model): Parses 'tracked_fiat_value' and 'fiat_currency' information from the given label. """ if self.confirmed_at_block_time: - obj, created = HistoricalPrice.get_or_create_from_api( + obj, created = HistoricalPrice.get_or_create_from_api(self.user, timestamp=self.confirmed_at_block_time) performance = 0 @@ -192,13 +206,11 @@ class OutputStat(models.Model): network=network).last() if cached_data: - print("found cached data {} for type_ref_hash {}, created {}".format(cached_data, type_ref_hash, created)) return cached_data, False def get_value_and_spent(txid, vout): mempool_api = MempoolAPI() res0 = mempool_api.get_transaction(txid) - print("res0 {}".format(res0)) vouts = res0.get("vout", []) if vouts: value = vouts[int(vout)].get("value", 0) @@ -210,10 +222,9 @@ class OutputStat(models.Model): if txid and vout: res = get_value_and_spent(txid, vout) - print (res) + if res: value, spent, confirmed_at_block_height, confirmed_at_block_time = res - print("called data {} {} for type_ref_hash {}".format(value, spent, type_ref_hash)) obj, created = cls.objects.get_or_create(user=user, type_ref_hash=type_ref_hash, network=network, defaults={ @@ -263,19 +274,33 @@ class HistoricalPrice(models.Model): class Meta: ordering = ['-timestamp'] - + @classmethod - def get_or_create_from_api(cls, timestamp=-1): - print("running get_or_create_from_api @ timestamp {}".format(timestamp)) + def get_or_create_from_api(cls, user=None, timestamp=-1): if timestamp == -1: current_datetime = datetime.datetime.now() timestamp = int(current_datetime.timestamp()) cached_data = cls.objects.filter(timestamp=timestamp).first() if cached_data: return cached_data, False - url = f"https://mempool.space/api/v1/historical-price?timestamp={timestamp}" - response = requests.get(url) - api_response = response.json() + try: + if user: + mempool_endpoint = user.profile.mempool_endpoint + else: + mempool_endpoint = "https://mempool.space" + url = f"{mempool_endpoint}/api/v1/historical-price?timestamp={timestamp}" + response = requests.get(url) + api_response = response.json() + except Exception as ex: + logger.error(ex, exc_info=True) + try: + from threadlocals.threadlocals import get_current_request + request = get_current_request() + if request: + messages.error(request, "Connection Error: Could not connect to Mempool to retrieve historical price.") + except Exception as ex2: + logger.error(ex2, exc_info=True) + return None, None try: obj, created = cls.objects.get_or_create(timestamp=timestamp, defaults={ 'usd_price': Decimal(str(api_response['prices'][0]['USD'])), diff --git a/django/finances/signals.py b/django/finances/signals.py index 40fdbe6..df9078e 100644 --- a/django/finances/signals.py +++ b/django/finances/signals.py @@ -1,20 +1,25 @@ from django.contrib.auth.signals import user_logged_in +from django.contrib import messages from django.dispatch import receiver from finances.models import HistoricalPrice -from django.contrib import messages - - - - @receiver(user_logged_in) def perform_tasks_on_login(sender, user, request, **kwargs): - """ """ - if user.profile.update_utxo_on_login: - from finances.tasks import check_all_outputs - from labelbase.models import Label - check_all_outputs(user.id) - if Label.objects.filter(labelbase__user_id=user.id).exists(): - messages.info(request, "Sync in progress: We are checking your unspent transaction outputs now.") + try: + if user.profile.update_utxo_on_login: + from finances.tasks import check_all_outputs + from labelbase.models import Label + check_all_outputs(user.id) + if Label.objects.filter(labelbase__user_id=user.id).exists(): + messages.info(request, ( + "Sync in progress: " + "We are checking your unspent transaction outputs now." + )) + except Exception as ex: + messages.info(request, ( + "Oups: " + f"{ex}" + )) + # Store nearest price information. - HistoricalPrice.get_or_create_from_api(-1) + HistoricalPrice.get_or_create_from_api(user, -1) diff --git a/django/finances/tasks.py b/django/finances/tasks.py index fb43e22..7816cbb 100644 --- a/django/finances/tasks.py +++ b/django/finances/tasks.py @@ -1,8 +1,6 @@ import logging from background_task import background from background_task.management.commands.remove_completed import _remove_completed_task - - from labelbase.models import Label from finances.electrum import checkup_label diff --git a/django/finances/tx_math.py b/django/finances/tx_math.py new file mode 100644 index 0000000..18940d5 --- /dev/null +++ b/django/finances/tx_math.py @@ -0,0 +1,223 @@ +# Constants +P2PKH_IN_SIZE = 148 +P2PKH_OUT_SIZE = 34 + +P2SH_OUT_SIZE = 32 +P2SH_P2WPKH_OUT_SIZE = 32 +P2SH_P2WSH_OUT_SIZE = 32 + +P2WPKH_OUT_SIZE = 31 +P2WSH_OUT_SIZE = P2TR_OUT_SIZE = 43 + +PUBKEY_SIZE = 33 +SIGNATURE_SIZE = 72 + + +def get_size_of_var_int(length): + if length <= 252: + return 1 + elif length <= 0xffff: + return 3 + elif length <= 0xffffffff: + return 5 + else: + return 9 + + +def get_size_of_script_length_element(length): + if length < 75: + return 1 + elif length <= 255: + return 2 + elif length <= 65535: + return 3 + elif length <= 4294967295: + return 5 + else: + raise ValueError("Script too large") + + +def get_input_size(input_attr): + """Return (base_size, witness_size) for a single input.""" + script_type = input_attr['input_script'] + m = input_attr.get('input_m', 0) + n = input_attr.get('input_n', 0) + + if script_type == "P2PKH": + return P2PKH_IN_SIZE, 0 + elif script_type == "P2SH": + # Redeem script: OP_m + n_pubkeys + OP_n + OP_CHECKMULTISIG + redeem_script_size = 1 + n * (1 + PUBKEY_SIZE) + 1 + 1 + # scriptSig: OP_0 + m_sigs + push_opcode + redeem_script + script_sig_size = 1 + m * (1 + SIGNATURE_SIZE) + get_size_of_script_length_element(redeem_script_size) + redeem_script_size + input_base_size = 32 + 4 + get_size_of_var_int(script_sig_size) + script_sig_size + 4 + return input_base_size, 0 + elif script_type == "P2SH-P2WPKH": + input_base_size = 32 + 4 + 1 + 23 + 4 + input_witness_size = 107 + return input_base_size, input_witness_size + elif script_type == "P2WPKH": + input_base_size = 32 + 4 + 1 + 4 + input_witness_size = 107 + return input_base_size, input_witness_size + elif script_type == "P2WSH": + input_base_size = 32 + 4 + 1 + 4 + witness_script_size = 1 + n * (1 + PUBKEY_SIZE) + 1 + 1 + num_stack_items = 1 + m + 1 # OP_0 + m sigs + witness script + input_witness_size = ( + get_size_of_var_int(num_stack_items) + + 1 + # OP_0 length + m * (1 + SIGNATURE_SIZE) + + get_size_of_var_int(witness_script_size) + + witness_script_size + ) + return input_base_size, input_witness_size + elif script_type == "P2TR": + input_base_size = 32 + 4 + 1 + 4 + input_witness_size = 65 + return input_base_size, input_witness_size + else: + raise ValueError(f"Unsupported input script type: {script_type}") + + +def calculate_transaction_size(inputs, output_counts): + """ + inputs: list of dicts with keys: input_script, input_m, input_n + output_counts: dict with counts per output type + """ + total_base = 0 + total_witness = 0 + + + # Total inputs / outputs + input_count = len(inputs) + output_count = sum(output_counts.values()) + + # Transaction overhead: version(4) + varints + locktime(4) + tx_base_size = 4 + get_size_of_var_int(input_count) + get_size_of_var_int(output_count) + 4 + + + # Segwit marker + flag if any input is segwit + has_witness = any(inp['input_script'] in ["P2SH-P2WPKH", "P2WPKH", "P2WSH", "P2TR"] for inp in inputs) + if has_witness: + total_witness += 2 # marker + flag + + for inp in inputs: + base, witness = get_input_size(inp) + total_base += base + total_witness += witness + + # Sum output sizes + output_size = (P2PKH_OUT_SIZE * output_counts.get('p2pkh', 0) + + P2SH_OUT_SIZE * output_counts.get('p2sh', 0) + + P2SH_P2WPKH_OUT_SIZE * output_counts.get('p2sh_p2wpkh', 0) + + P2SH_P2WSH_OUT_SIZE * output_counts.get('p2sh_p2wsh', 0) + + P2WPKH_OUT_SIZE * output_counts.get('p2wpkh', 0) + + P2WSH_OUT_SIZE * output_counts.get('p2wsh', 0) + + P2TR_OUT_SIZE * output_counts.get('p2tr', 0)) + + + # Total base size + tx_total_base_size = tx_base_size + total_base + output_size + + # Transaction weight and vbytes + tx_weight = tx_total_base_size * 4 + total_witness + tx_vbytes = tx_weight / 4 + + # Raw bytes (base + witness discounted by 1/4) + tx_bytes = tx_total_base_size + total_witness / 4 + + return { + 'txBytes': round(tx_bytes), + 'txVBytes': round(tx_vbytes), + 'txWeight': tx_weight + } + + + +def calculate_fee(tx_vbytes, fee_rate_sats_per_vbyte): + """ + tx_vbytes: virtual size from calculate_transaction_size() + fee_rate_sats_per_vbyte: fee rate in sats per vbyte + """ + return round(tx_vbytes * fee_rate_sats_per_vbyte) + + + +def run_tests(): + fee_rate = 20 # sats per vbyte + test_cases = [ + # 1. Single P2PKH input -> single P2PKH output + { + 'inputs': [{'input_script': 'P2PKH'}], + 'outputs': {'p2pkh': 1}, + 'description': 'Single P2PKH -> P2PKH' + }, + # 2. Two P2WPKH inputs -> two P2WPKH outputs + { + 'inputs': [{'input_script': 'P2WPKH'}, {'input_script': 'P2WPKH'}], + 'outputs': {'p2wpkh': 2}, + 'description': 'Two P2WPKH -> Two P2WPKH' + }, + # 3. Single P2SH 2-of-3 multisig input -> two P2PKH outputs + { + 'inputs': [{'input_script': 'P2SH', 'input_m': 2, 'input_n': 3}], + 'outputs': {'p2pkh': 2}, + 'description': 'P2SH 2-of-3 multisig -> 2x P2PKH' + }, + # 4. Mixed inputs: P2PKH + P2WPKH + P2TR -> P2PKH + P2WPKH + { + 'inputs': [ + {'input_script': 'P2PKH'}, + {'input_script': 'P2WPKH'}, + {'input_script': 'P2TR'} + ], + 'outputs': {'p2pkh': 1, 'p2wpkh': 1}, + 'description': 'Mixed inputs -> mixed outputs' + }, + # 5. Two P2WSH multisig inputs -> P2WSH outputs + { + 'inputs': [ + {'input_script': 'P2WSH', 'input_m': 2, 'input_n': 3}, + {'input_script': 'P2WSH', 'input_m': 1, 'input_n': 2} + ], + 'outputs': {'p2wsh': 2}, + 'description': 'Two P2WSH -> Two P2WSH' + } + ] + + for idx, test in enumerate(test_cases, 1): + # Step 1: calculate size + tx_size = calculate_transaction_size(test['inputs'], test['outputs']) + + # Step 2: calculate fee + fee_sats = calculate_fee(tx_size['txVBytes'], fee_rate) + + print(f"Test {idx}: {test['description']}") + print(f" txBytes: {tx_size['txBytes']}, txVBytes: {tx_size['txVBytes']}, txWeight: {tx_size['txWeight']}") + print(f" Fee (@ {fee_rate} sats/vbyte): {fee_sats} sats\n") + + + +# Example usage +if __name__ == "__main__": + inputs = [ + {'input_script': 'P2PKH'}, + {'input_script': 'P2WPKH'}, + {'input_script': 'P2SH', 'input_m': 2, 'input_n': 3}, + ] + output_counts = { + 'p2pkh': 1, + 'p2sh': 0, + 'p2sh_p2wpkh': 1, + 'p2sh_p2wsh': 0, + 'p2wpkh': 0, + 'p2wsh': 0, + 'p2tr': 0 + } + + tx_size = calculate_transaction_size(inputs, output_counts) + print(tx_size) + + print("*"*80) + run_tests() diff --git a/django/finances/tx_math_tests.py b/django/finances/tx_math_tests.py new file mode 100644 index 0000000..4ad610e --- /dev/null +++ b/django/finances/tx_math_tests.py @@ -0,0 +1,48 @@ +def run_tests(): + test_cases = [ + # 1. Single P2PKH input -> single P2PKH output + { + 'inputs': [{'input_script': 'P2PKH'}], + 'outputs': {'p2pkh': 1}, + 'description': 'Single P2PKH -> P2PKH' + }, + # 2. Two P2WPKH inputs -> two P2WPKH outputs + { + 'inputs': [{'input_script': 'P2WPKH'}, {'input_script': 'P2WPKH'}], + 'outputs': {'p2wpkh': 2}, + 'description': 'Two P2WPKH -> Two P2WPKH' + }, + # 3. Single P2SH 2-of-3 multisig input -> two P2PKH outputs + { + 'inputs': [{'input_script': 'P2SH', 'input_m': 2, 'input_n': 3}], + 'outputs': {'p2pkh': 2}, + 'description': 'P2SH 2-of-3 multisig -> 2x P2PKH' + }, + # 4. Mixed inputs: P2PKH + P2WPKH + P2TR -> P2PKH + P2WPKH + { + 'inputs': [ + {'input_script': 'P2PKH'}, + {'input_script': 'P2WPKH'}, + {'input_script': 'P2TR'} + ], + 'outputs': {'p2pkh': 1, 'p2wpkh': 1}, + 'description': 'Mixed inputs -> mixed outputs' + }, + # 5. Two P2WSH multisig inputs -> P2WSH outputs + { + 'inputs': [ + {'input_script': 'P2WSH', 'input_m': 2, 'input_n': 3}, + {'input_script': 'P2WSH', 'input_m': 1, 'input_n': 2} + ], + 'outputs': {'p2wsh': 2}, + 'description': 'Two P2WSH -> Two P2WSH' + } + ] + + for idx, test in enumerate(test_cases, 1): + result = calculate_transaction_size(test['inputs'], test['outputs']) + print(f"Test {idx}: {test['description']}") + print(f" txBytes: {result['txBytes']}, txVBytes: {result['txVBytes']}, txWeight: {result['txWeight']}\n") + +if __name__ == "__main__": + run_tests() diff --git a/django/importer/forms.py b/django/importer/forms.py index d3ae1ca..e14fe52 100644 --- a/django/importer/forms.py +++ b/django/importer/forms.py @@ -8,3 +8,8 @@ class UploadFileForm(forms.Form): choices=IMPORTER_CHOICES ) file = forms.FileField() + passphrase = forms.CharField( + widget=forms.PasswordInput(), + required=False, + max_length=100 + ) diff --git a/django/importer/models.py b/django/importer/models.py index 82cafed..11c8f31 100644 --- a/django/importer/models.py +++ b/django/importer/models.py @@ -2,15 +2,18 @@ from django.db import models from django.contrib.auth.models import User from labelbase.models import Labelbase from uuid_upload_path import upload_to +from django.conf import settings -IMPORTER_CHOICES = ( +IMPORTER_CHOICES = [ ("BIP-0329", "BIP-329 .jsonl"), # TODO: ("BIP-0329-7z-enc" , "BIP-329 (encrypted) .7z"), ("csv-bluewallet", "BlueWallet .csv"), ("csv-bitbox", "BitBox .csv"), - ("pocket-accointing", "Pocket Accointing .csv") -) + ("pocket-accointing", "Pocket Accointing .csv"), +] +if settings.SELF_HOSTED: + IMPORTER_CHOICES.append(("samourai", "Samourai .txt, (v2)")) class UploadedData(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) diff --git a/django/importer/pocket.py b/django/importer/pocket.py index bbc88ca..9d8af30 100644 --- a/django/importer/pocket.py +++ b/django/importer/pocket.py @@ -1,7 +1,5 @@ import csv -import json -from labelbase.models import Label -from labelbase.models import Labelbase +import json def validate_csv_format(csv_file_path): diff --git a/django/importer/samourai.py b/django/importer/samourai.py new file mode 100644 index 0000000..91fb3b1 --- /dev/null +++ b/django/importer/samourai.py @@ -0,0 +1,151 @@ +import json +from Crypto.Cipher import AES +from Crypto.Hash import SHA256 +from Crypto.Protocol.KDF import PBKDF2 +import hashlib +import re +import base64 + +from labelbase.serializers import LabelSerializer +from labelbase.models import Label + +import logging +logger = logging.getLogger('labelbase') + +DefaultPBKDF2Iterations = 5000 +DefaultPBKDF2HMACSHA256Iterations = 15000 +DefaultSamouraiImportLabel = "Imported form samourai.txt" + + + + + +def decrypt_v1(payload, password, iterations=DefaultPBKDF2Iterations): + # V1 uses PBKDF2 for key derivation and AES for decryption + AESBlockSize = 16 + cipherdata = base64.b64decode(payload) + iv = cipherdata[:AESBlockSize] + input_data = cipherdata[AESBlockSize:] + key = PBKDF2(password, iv, dkLen=32, count=iterations) + cipher = AES.new(key, AES.MODE_CBC, iv) + decrypted = cipher.decrypt(input_data) + return decrypted.rstrip(b"\x00").decode('utf-8') + + +def decrypt_v2(payload, password, iterations=DefaultPBKDF2HMACSHA256Iterations): + # V2 uses SHA256 for key derivation and AES for decryption + encrypted_bytes = base64.b64decode(payload.replace("\n", "")) + salt = encrypted_bytes[8:16] + cipher_text = encrypted_bytes[16:] + key_iv = PBKDF2(password, salt, dkLen=48, count=iterations, hmac_hash_module=SHA256) + key = key_iv[:32] + iv = key_iv[32:] + cipher = AES.new(key, AES.MODE_CBC, iv) + decrypted = cipher.decrypt(cipher_text) + pad_len = decrypted[-1] + decrypted = decrypted[:-pad_len] + return decrypted.decode('utf-8') + + +def import_samourai_labels(labelbase, content, passphrase): + content = content.decode('utf-8') + pattern = re.compile(r'\{.*?\}') + match = pattern.search(content) + imported_lables = 0 + payload = None + if match: + json_content = match.group(0) + try: + logger.info(f"json_content {json_content}") + data = json.loads(json_content) + logger.info(f"data: {data}") + version = data.get("version", 1) + payload = data.get("payload", "") + if payload: + if version in [1, "1"]: + decrypted_data = decrypt_v1(payload, passphrase) + elif version in [2, "2"]: + decrypted_data = decrypt_v2(payload, passphrase) + else: + logger.error(f"Unsupported backup version: {version}") + raise ValueError(f"Unsupported backup version: {version}") + logger.info(decrypted_data) + samourai_data = json.loads(decrypted_data) + logger.info(samourai_data) + + """ + DOC/KB: If the labelbase where you import your samourai.txt into, labelbase will set the fingerprint, + """ + labels = Label.objects.filter(labelbase__id=labelbase.id) + if labels.count() == 0: + if not labelbase.fingerprint: + labelbase.fingerprint = samourai_data.get('wallet').get('fingerprint') + if samourai_data.get('wallet').get('testnet'): + labelbase.network == labelbase.TESTNET + else: + labelbase.network == labelbase.MAINNET + labelbase.save() + + xpub = samourai_data.get('wallet', {}).get('accounts')[0].get('xpub') + ypub = samourai_data.get('wallet', {}).get('bip49_accounts')[0].get('ypub') + zpub = samourai_data.get('wallet', {}).get('bip84_accounts')[0].get('zpub') + + for pub in [xpub, ypub, zpub]: + if pub: + _data = { + "type": Label.TYPE_XPUB, + "ref": pub, + "label": DefaultSamouraiImportLabel, + } + _data["labelbase"] = labelbase.id + serializer = LabelSerializer(data=_data) + if serializer.is_valid(): + serializer.save() + imported_lables += 1 + + utxo_notes = samourai_data.get('meta', {}).get('utxo_notes') + logger.info(utxo_notes) + for note in utxo_notes: + _data = { + "type": Label.TYPE_TX, + "ref": note[0], + "label": note[1], + } + _data["labelbase"] = labelbase.id + serializer = LabelSerializer(data=_data) + if serializer.is_valid(): + serializer.save() + imported_lables += 1 + blocked_utxos = samourai_data.get('meta', {}).get('blocked_utxos',{}).get('blocked') + logger.info(blocked_utxos) + for utxo in blocked_utxos: + _data = { + "type": Label.TYPE_OUTPUT, + "ref": utxo.get('id','').replace("-", ":"), + "label": DefaultSamouraiImportLabel, + "spendable": False + } + _data["labelbase"] = labelbase.id + serializer = LabelSerializer(data=_data) + if serializer.is_valid(): + serializer.save() + imported_lables += 1 + return imported_lables + + else: + print("No payload found in the JSON content.") + logger.error("No payload found in the JSON content.") + return imported_lables + except json.JSONDecodeError as e: + print(f"JSONDecodeError: {e}") + logger.error(f"JSONDecodeError: {e}") + return imported_lables + except Exception as ex: + print(f"An error occurred: {ex}") + logger.error(f"An error occurred: {ex}") + logger.error(ex, exc_info=True) + return imported_lables + else: + print("No JSON found in file.") + logger.error("No JSON found in file.") + return imported_lables diff --git a/django/importer/tasks.py b/django/importer/tasks.py index a29b72b..d147798 100644 --- a/django/importer/tasks.py +++ b/django/importer/tasks.py @@ -2,9 +2,10 @@ from background_task import background import json import decimal -from labelbase.models import Labelbase from labelbase.serializers import LabelSerializer +import logging +logger = logging.getLogger('labelbase') from .models import UploadedData @@ -12,93 +13,135 @@ EOLSTOP = [b"", "", None, "\n"] @background(schedule=1) -def process_uploaded_data(uploaded_data_id, loop=None): - imported_lables = 0 - uploaded_data = UploadedData.objects.get(pk=uploaded_data_id) - labelbase = uploaded_data.labelbase - fp = uploaded_data.file.open() +def process_uploaded_data(uploaded_data_id, passphrase=None, loop=None): + try: + uploaded_data = UploadedData.objects.get(pk=uploaded_data_id) + imported_lables = 0 + labelbase = uploaded_data.labelbase + fp = uploaded_data.file.open() - # BIP-0329 - if uploaded_data.import_type == "BIP-0329": - while True: - buf = fp.readline() - if buf in EOLSTOP: - break - data = json.loads(buf) - data["labelbase"] = labelbase.id - serializer = LabelSerializer(data=data) - if serializer.is_valid(): - serializer.save() - imported_lables += 1 - elif uploaded_data.import_type == "BIP-0329-7z-enc": - # TODO: Implementation needed. - pass - # Bitbox App - elif uploaded_data.import_type == "csv-bitbox": - while True: - buf = fp.readline() - if buf in EOLSTOP: - break - try: - buf = str(buf.decode("utf-8")) - sbuf = buf.split(",") - # Time,Type,Amount,Unit,Fee,Address,Transaction ID,Note - for elem in [("tx", 6), ("addr", 5)]: - data = { - "type": elem[0], - "ref": sbuf[elem[1]], - "label": " ".join(sbuf[7:]), - } - data["labelbase"] = labelbase.id - serializer = LabelSerializer(data=data) - if serializer.is_valid(): - serializer.save() - imported_lables += 1 - else: - messages.add_message( - request, - messages.ERROR, - 'Could not process line "{}".'.format(buf), - ) - except Exception as ex: - messages.add_message( - request, - messages.ERROR, - 'Could not process line "{}", {}.'.format(buf, ex), - ) - # Pocket Accointing - elif uploaded_data.import_type == "pocket-accointing": - fp.close() - csv_file_path = fp.name - mempool_api = labelbase.get_mempool_api() - from .pocket import validate_csv_format, parse_csv_to_json - if validate_csv_format(csv_file_path): - for item in parse_csv_to_json(csv_file_path): - label = "Got {} {} for {:.2f} {} with reference: {} #Pocket".format(item[0].get('outSellAmount'), item[1].get( - 'inBuyAsset'), decimal.Decimal(item[2].get('inBuyAmount')), item[2].get('inBuyAsset'), item[1].get('operationId')) - txid = item[0].get('operationId') - tx = mempool_api.get_transaction(txid) - potential_utxos = [] - vouts = tx.get("vout", []) - for i in range(len(vouts)): - if vouts[i].get('value', 0) == decimal.Decimal(item[0].get('outSellAmount'))*100000000: - potential_utxos.append("{}:{}".format(txid, i )) - data = {} - if len(potential_utxos) == 1: - # label UTXO/output of tx - data = { - "type": "output", - "ref": potential_utxos[0], - "label": label, - } - if len(potential_utxos) > 1: - # mark tx, add warning tag + # BIP-0329 + if uploaded_data.import_type == "BIP-0329": + while True: + buf = fp.readline() + if buf in EOLSTOP: + break + data = json.loads(buf) + + logger.info(f"Parsed data: {data}") + + data["labelbase"] = labelbase.id + serializer = LabelSerializer(data=data) + if serializer.is_valid(): + serializer.save() + imported_lables += 1 + elif uploaded_data.import_type == "BIP-0329-7z-enc": + # TODO: Implementation needed. + pass + elif uploaded_data.import_type == "samourai": + buf = fp.read() + logger.info(buf) + print(buf) + from .samourai import import_samourai_labels + import_samourai_labels(labelbase, buf, passphrase) + # Bitbox App + elif uploaded_data.import_type == "csv-bitbox": + while True: + buf = fp.readline() + if buf in EOLSTOP: + break + try: + buf = str(buf.decode("utf-8")) + sbuf = buf.split(",") + # Time,Type,Amount,Unit,Fee,Address,Transaction ID,Note + for elem in [("tx", 6), ("addr", 5)]: + data = { + "type": elem[0], + "ref": sbuf[elem[1]], + "label": " ".join(sbuf[7:]), + } + data["labelbase"] = labelbase.id + serializer = LabelSerializer(data=data) + if serializer.is_valid(): + serializer.save() + imported_lables += 1 + else: + messages.add_message( + request, + messages.ERROR, + 'Could not process line "{}".'.format(buf), + ) + except Exception as ex: + messages.add_message( + request, + messages.ERROR, + 'Could not process line "{}", {}.'.format(buf, ex), + ) + # Pocket Accointing + elif uploaded_data.import_type == "pocket-accointing": + fp.close() + csv_file_path = fp.name + mempool_api = labelbase.get_mempool_api() + from .pocket import validate_csv_format, parse_csv_to_json + if validate_csv_format(csv_file_path): + for item in parse_csv_to_json(csv_file_path): + label = "Got {} {} for {:.2f} {} with reference: {} #Pocket".format(item[0].get('outSellAmount'), item[1].get( + 'inBuyAsset'), decimal.Decimal(item[2].get('inBuyAmount')), item[2].get('inBuyAsset'), item[1].get('operationId')) + txid = item[0].get('operationId') + tx = mempool_api.get_transaction(txid) + potential_utxos = [] + vouts = tx.get("vout", []) + for i in range(len(vouts)): + if vouts[i].get('value', 0) == decimal.Decimal(item[0].get('outSellAmount'))*100000000: + potential_utxos.append("{}:{}".format(txid, i )) + data = {} + if len(potential_utxos) == 1: + # label UTXO/output of tx + data = { + "type": "output", + "ref": potential_utxos[0], + "label": label, + } + if len(potential_utxos) > 1: + # mark tx, add warning tag + data = { + "type": "tx", + "ref": txid, + "label": "{} {}".format(label, "#W001_UTXO_NOT_FOUND"), + } + if data: + data["labelbase"] = labelbase.id + serializer = LabelSerializer(data=data) + if serializer.is_valid(): + serializer.save() + imported_lables += 1 + else: + # messages.add_message( + # request, + # messages.ERROR, + # 'Could not process record "{}".'.format(item), + # ) + pass + else: + print("ERROR") # TODO + # BlueWallet + elif uploaded_data.import_type == "csv-bluewallet": + header_row = True + while True: + buf = fp.readline() + if buf in EOLSTOP: + break + if header_row: + header_row = False + continue + try: + buf = str(buf.decode("utf-8")) + sbuf = buf.split(",") data = { "type": "tx", - "ref": txid, - "label": "{} {}".format(label, "#W001_UTXO_NOT_FOUND"), + "ref": sbuf[1], + "label": " ".join(sbuf[3:]), } - if data: data["labelbase"] = labelbase.id serializer = LabelSerializer(data=data) if serializer.is_valid(): @@ -108,50 +151,20 @@ def process_uploaded_data(uploaded_data_id, loop=None): # messages.add_message( # request, # messages.ERROR, - # 'Could not process record "{}".'.format(item), + # 'Could not process line "{}".'.format(buf), # ) pass - else: - print("ERROR") # TODO - # BlueWallet - elif uploaded_data.import_type == "csv-bluewallet": - header_row = True - while True: - buf = fp.readline() - if buf in EOLSTOP: - break - if header_row: - header_row = False - continue - try: - buf = str(buf.decode("utf-8")) - sbuf = buf.split(",") - data = { - "type": "tx", - "ref": sbuf[1], - "label": " ".join(sbuf[3:]), - } - data["labelbase"] = labelbase.id - serializer = LabelSerializer(data=data) - if serializer.is_valid(): - serializer.save() - imported_lables += 1 - else: + except Exception as ex: # messages.add_message( # request, # messages.ERROR, - # 'Could not process line "{}".'.format(buf), + # 'Could not process line "{}", {}.'.format(buf, ex), # ) pass - except Exception as ex: - # messages.add_message( - # request, - # messages.ERROR, - # 'Could not process line "{}", {}.'.format(buf, ex), - # ) - pass - # Clean up – Note: Currently we delete the upload from the file system, - # later we can store the messages.add_message messages, the state and the - # amount of importet labels in it to propagate the messages to the - # frontend/user interface. - uploaded_data.delete() + # Clean up – Note: Currently we delete the upload from the file system, + # later we can store the messages.add_message messages, the state and the + # amount of importet labels in it to propagate the messages to the + # frontend/user interface. + uploaded_data.delete() + except Exception as ex: + logger.error(ex, exc_info=True) diff --git a/django/importer/templatetags/importer_tags.py b/django/importer/templatetags/importer_tags.py index dc43461..a10cfb0 100644 --- a/django/importer/templatetags/importer_tags.py +++ b/django/importer/templatetags/importer_tags.py @@ -10,27 +10,4 @@ def genericlabeluploadform(labelbase_id): form.fields["labelbase_id"].initial = labelbase_id form.fields["import_type"].initial = "BIP-0329" return form - - -@register.simple_tag -def bip0329labeluploadform(labelbase_id): - form = UploadFileForm() - form.fields["labelbase_id"].initial = labelbase_id - form.fields["import_type"].initial = "BIP-0329" - return form - - -@register.simple_tag -def csvBlueWalletlabeluploadform(labelbase_id): - form = UploadFileForm() - form.fields["labelbase_id"].initial = labelbase_id - form.fields["import_type"].initial = "csv-bluewallet" - return form - - -@register.simple_tag -def csvBitBoxLabeluploadform(labelbase_id): - form = UploadFileForm() - form.fields["labelbase_id"].initial = labelbase_id - form.fields["import_type"].initial = "csv-bitbox" - return form + diff --git a/django/importer/views.py b/django/importer/views.py index 62e1dd1..7b72d57 100644 --- a/django/importer/views.py +++ b/django/importer/views.py @@ -30,7 +30,7 @@ def upload_labels(request): file=request.FILES["file"], ) # Schedule the background task to process the uploaded data - process_uploaded_data(uploaded_data.id) + process_uploaded_data(uploaded_data.id, passphrase=form.cleaned_data.get("passphrase", None)) messages.add_message( request, messages.INFO, diff --git a/django/labelbase/admin.py b/django/labelbase/admin.py index 358c694..a24e712 100644 --- a/django/labelbase/admin.py +++ b/django/labelbase/admin.py @@ -1,6 +1,36 @@ from django.contrib import admin from .models import Labelbase from .models import Label +from django.conf import settings -admin.site.register(Labelbase) -admin.site.register(Label) +class LabelbaseAdmin(admin.ModelAdmin): + list_display = ['id', 'user', 'name', 'network', 'operation_mode'] + list_filter = ['network', 'operation_mode'] + search_fields = ['name', 'fingerprint'] + + +class LabelAdmin(admin.ModelAdmin): + list_display = ['id', 'type', 'labelbase', 'label'] + list_filter = ['type', 'labelbase__network'] + search_fields = ['ref', 'label'] + + # Organize fields into logical sections + fieldsets = ( + ('Core BIP-329 Fields', { + 'fields': ('labelbase', 'type', 'ref', 'label', 'origin', 'spendable') + }), + ('Additional BIP-329 Fields', { + 'fields': ('height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights'), + 'classes': ('collapse',), # Make this section collapsible + }), + ('Internal', { + 'fields': ('type_ref_hash',), + 'classes': ('collapse',), + }), + ) + + readonly_fields = ['type_ref_hash'] + +if settings.DEBUG: + admin.site.register(Labelbase, LabelbaseAdmin) + admin.site.register(Label, LabelAdmin) diff --git a/django/labelbase/api.py b/django/labelbase/api.py index 2ec8a2d..5781795 100644 --- a/django/labelbase/api.py +++ b/django/labelbase/api.py @@ -10,6 +10,9 @@ from labelbase.models import Labelbase, Label from labelbase.serializers import LabelbaseSerializer, LabelSerializer +import logging +logger = logging.getLogger('labelbase') + class LabelbaseAPIView(APIView): """ Labelbase @@ -90,7 +93,7 @@ class LabelAPIView(APIView): "spendable": request.data.get("spendable", "null"), } - + #logger.debug(f"data: {data}") serializer = LabelSerializer(data=data) if serializer.is_valid(): serializer.save() diff --git a/django/labelbase/forms.py b/django/labelbase/forms.py index c7cf230..d2fcec6 100644 --- a/django/labelbase/forms.py +++ b/django/labelbase/forms.py @@ -36,7 +36,7 @@ class LabelForm(forms.ModelForm): for field_name in self.fields: self.fields[field_name].label = mark_safe( f'') - + class ExportLabelsForm(forms.Form): """ """ diff --git a/django/labelbase/migrations/0012_auto_20251122_0800.py b/django/labelbase/migrations/0012_auto_20251122_0800.py new file mode 100644 index 0000000..c84a26b --- /dev/null +++ b/django/labelbase/migrations/0012_auto_20251122_0800.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.25 on 2025-11-22 08:00 + +from django.db import migrations, models +import django_cryptography.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('labelbase', '0011_alter_labelbase_operation_mode'), + ] + + operations = [ + migrations.AddField( + model_name='label', + name='fee', + field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Transaction fee in satoshis (stored as string)', max_length=32, null=True)), + ), + migrations.AddField( + model_name='label', + name='fmv', + field=django_cryptography.fields.encrypt(models.TextField(blank=True, help_text='Fair market value (JSON string)', null=True)), + ), + migrations.AddField( + model_name='label', + name='height', + field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Block height where transaction was confirmed', max_length=16, null=True)), + ), + migrations.AddField( + model_name='label', + name='heights', + field=django_cryptography.fields.encrypt(models.TextField(blank=True, help_text='Block heights for address activity (JSON array as string)', null=True)), + ), + migrations.AddField( + model_name='label', + name='keypath', + field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Key derivation path (e.g., /1/123)', max_length=256, null=True)), + ), + migrations.AddField( + model_name='label', + name='rate', + field=django_cryptography.fields.encrypt(models.TextField(blank=True, help_text='Exchange rate at transaction time (JSON string)', null=True)), + ), + migrations.AddField( + model_name='label', + name='time', + field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='ISO-8601 timestamp of the block', max_length=64, null=True)), + ), + migrations.AddField( + model_name='label', + name='value', + field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Transaction value in satoshis, signed (stored as string)', max_length=32, null=True)), + ), + ] diff --git a/django/labelbase/models.py b/django/labelbase/models.py index 4020547..2edb72d 100644 --- a/django/labelbase/models.py +++ b/django/labelbase/models.py @@ -2,6 +2,7 @@ from django.db import models from django.contrib.auth.models import User from django.urls import reverse from django_cryptography.fields import encrypt +from django.utils.safestring import mark_safe from pymempool import MempoolAPI @@ -112,6 +113,13 @@ 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): """ @@ -123,7 +131,7 @@ class Label(models.Model): TYPE_PUBKEY = "pubkey" TYPE_INPUT = "input" TYPE_OUTPUT = "output" - TYPE_XPUT = "xpub" + TYPE_XPUB = "xpub" TYPE_CHOICES = [ (TYPE_TX, "tx"), @@ -131,7 +139,7 @@ class Label(models.Model): (TYPE_PUBKEY, "pubkey"), (TYPE_INPUT, "input"), (TYPE_OUTPUT, "output"), - (TYPE_XPUT, "xpub"), + (TYPE_XPUB, "xpub"), ] type = models.CharField( @@ -173,9 +181,77 @@ class Label(models.Model): ) ) - labelbase = models.ForeignKey(Labelbase, on_delete=models.CASCADE) + labelbase = models.ForeignKey( + Labelbase, + on_delete=models.CASCADE + ) - type_ref_hash = models.CharField(max_length=64, blank=True) + type_ref_hash = models.CharField( + max_length=64, + blank=True) + + # All additional fields (from the BIP-329 upgrade) encrypted for maximum privacy + height = encrypt( + models.CharField( + max_length=16, + null=True, + blank=True, + help_text="Block height where transaction was confirmed" + ) + ) + time = encrypt( + models.CharField( + max_length=64, + null=True, + blank=True, + help_text="ISO-8601 timestamp of the block" + ) + ) + fee = encrypt( + models.CharField( + max_length=32, + null=True, + blank=True, + help_text="Transaction fee in satoshis (stored as string)" + ) + ) + value = encrypt( + models.CharField( + max_length=32, + null=True, + blank=True, + help_text="Transaction value in satoshis, signed (stored as string)" + ) + ) + rate = encrypt( + models.TextField( + null=True, + blank=True, + help_text="Exchange rate at transaction time (JSON string)" + ) + ) + keypath = encrypt( + models.CharField( + max_length=256, + null=True, + blank=True, + help_text="Key derivation path (e.g., /1/123)" + ) + ) + fmv = encrypt( + models.TextField( + null=True, + blank=True, + help_text="Fair market value (JSON string)" + ) + ) + heights = encrypt( + models.TextField( + null=True, + blank=True, + help_text="Block heights for address activity (JSON array as string)" + ) + ) def get_extracted_fiat_value(self): return extract_fiat_value(self.label) @@ -201,7 +277,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 lable was saved. + This brings us back to the labelbase once the label was saved. """ return self.labelbase.get_absolute_url() @@ -223,3 +299,102 @@ class Label(models.Model): except: pass return "" + + + def get_fee_health_status(self): + """ + Calculate fee health status for this label if it's a spendable unspent output. + """ + # Only calculate for spendable outputs + if self.type != self.TYPE_OUTPUT or not self.spendable: + return { + 'status': None, + 'fee_sats': None, + 'value_sats': None, + 'fee_percentage': None, + 'threshold_healthy': None, + 'threshold_warning': None, + 'threshold_high': None + } + + try: + value_sats = int(self.value) if self.value else None + except (ValueError, TypeError): + value_sats = None + + if not value_sats or value_sats <= 0: + return { + 'status': None, + 'fee_sats': None, + 'value_sats': value_sats, + 'fee_percentage': None, + 'threshold_healthy': None, + 'threshold_warning': None, + 'threshold_high': None + } + + # Get user's fee rate from profile + user_fee_rate = self.labelbase.user.profile.my_fee # sats per vbyte + threshold_adjustment = self.labelbase.user.profile.my_fee_rate_threshold # percentage points + + # Use P2WPKH as default - most common modern type + # Simple 1-in, 2-out transaction + from finances.tx_math import calculate_transaction_size, calculate_fee + + inputs = [{'input_script': 'P2WPKH'}] + output_counts = {'p2wpkh': 2} + + tx_size = calculate_transaction_size(inputs, output_counts) + fee_sats = calculate_fee(tx_size['txVBytes'], user_fee_rate) + + # Calculate fee as percentage of output value + fee_percentage = (fee_sats / value_sats) * 100 + + # Define thresholds (base + user adjustment) + threshold_healthy = 1.0 + threshold_adjustment + threshold_warning = 3.0 + threshold_adjustment + + # Determine status + if fee_percentage < threshold_healthy: + status = 'green' + elif fee_percentage < threshold_warning: + status = 'yellow' + else: + status = 'red' + + return { + 'status': status, + 'fee_sats': fee_sats, + 'value_sats': value_sats, + 'fee_percentage': round(fee_percentage, 3), + 'threshold_healthy': threshold_healthy, + 'threshold_warning': threshold_warning, + 'threshold_high': threshold_warning + } + + + @property + def get_fee_health_status_display(self): + """ + Returns text representation of fee health status for DataTables display. + """ + health = self.get_fee_health_status() + + if not health['status']: + return '' + + status_map = { + 'green': '🟢', + 'yellow': '🟡', + 'red': '🔴' + } + # FIXME: escaping in data tables + #status_map = { + # 'green': '', + # 'yellow': '', + # 'red': '' + #} + + emoji = status_map.get(health['status'], '') + + return f"{emoji} {health['fee_percentage']}%" diff --git a/django/labelbase/serializers.py b/django/labelbase/serializers.py index d24f4aa..3f16523 100644 --- a/django/labelbase/serializers.py +++ b/django/labelbase/serializers.py @@ -1,8 +1,9 @@ +import json from rest_framework import serializers from labelbase.models import Labelbase, Label -class LabelSerializer(serializers.ModelSerializer): +class LabelSerializer_v1(serializers.ModelSerializer): class Meta: model = Label fields = [ @@ -19,6 +20,169 @@ class LabelSerializer(serializers.ModelSerializer): ] +class LabelSerializer(serializers.ModelSerializer): + # Additional BIP-329 fields + height = serializers.IntegerField(required=False, allow_null=True) + time = serializers.CharField(required=False, allow_null=True, allow_blank=True) + fee = serializers.IntegerField(required=False, allow_null=True) + value = serializers.IntegerField(required=False, allow_null=True) + rate = serializers.JSONField(required=False, allow_null=True) + keypath = serializers.CharField(required=False, allow_null=True, allow_blank=True) + fmv = serializers.JSONField(required=False, allow_null=True) + heights = serializers.ListField( + child=serializers.IntegerField(), + required=False, + allow_null=True + ) + + class Meta: + model = Label + fields = [ + "id", + "labelbase", + "type", + "ref", + "label", + "origin", + "spendable", + # Additional BIP-329 fields + "height", + "time", + "fee", + "value", + "rate", + "keypath", + "fmv", + "heights", + ] + read_only_fields = [ + "id", + ] + + def validate(self, data): + """Validate BIP-329 field combinations based on type""" + label_type = data.get('type') + + # Define valid fields per type (from BIP-329 spec) + valid_fields = { + 'tx': {'height', 'time', 'fee', 'value', 'rate'}, + 'addr': {'keypath', 'heights'}, + 'pubkey': {'keypath'}, + 'input': {'keypath', 'value', 'fmv', 'height', 'time'}, + 'output': {'spendable', 'keypath', 'value', 'fmv', 'height', 'time'}, + 'xpub': set() + } + + # Get allowed additional fields for this type + allowed = valid_fields.get(label_type, set()) + + # Check for invalid field combinations + additional_fields = {'height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights', 'spendable'} + for field in additional_fields: + if field in data and data[field] is not None: + # Allow origin for all types + if field == 'origin': + continue + # Check if field is valid for this type + if field not in allowed and field in additional_fields - {'origin'}: + # Remove invalid field instead of raising error (for compatibility) + data.pop(field, None) + + return data + + def create(self, validated_data): + """Override create to convert data types for storage""" + # Convert integers to strings for storage + if 'height' in validated_data and validated_data['height'] is not None: + validated_data['height'] = str(validated_data['height']) + + if 'fee' in validated_data and validated_data['fee'] is not None: + validated_data['fee'] = str(validated_data['fee']) + + if 'value' in validated_data and validated_data['value'] is not None: + validated_data['value'] = str(validated_data['value']) + + # Convert JSON objects to strings + if 'rate' in validated_data and validated_data['rate'] is not None: + validated_data['rate'] = json.dumps(validated_data['rate']) + + if 'fmv' in validated_data and validated_data['fmv'] is not None: + validated_data['fmv'] = json.dumps(validated_data['fmv']) + + if 'heights' in validated_data and validated_data['heights'] is not None: + validated_data['heights'] = json.dumps(validated_data['heights']) + instance = super().create(validated_data) + return instance + + def update(self, instance, validated_data): + """Override update to convert data types for storage""" + # Convert integers to strings for storage + if 'height' in validated_data and validated_data['height'] is not None: + validated_data['height'] = str(validated_data['height']) + + if 'fee' in validated_data and validated_data['fee'] is not None: + validated_data['fee'] = str(validated_data['fee']) + + if 'value' in validated_data and validated_data['value'] is not None: + validated_data['value'] = str(validated_data['value']) + + # Convert JSON objects to strings + if 'rate' in validated_data and validated_data['rate'] is not None: + validated_data['rate'] = json.dumps(validated_data['rate']) + + if 'fmv' in validated_data and validated_data['fmv'] is not None: + validated_data['fmv'] = json.dumps(validated_data['fmv']) + + if 'heights' in validated_data and validated_data['heights'] is not None: + validated_data['heights'] = json.dumps(validated_data['heights']) + + return super().update(instance, validated_data) + + def to_representation(self, instance): + """Convert stored data back to API format""" + data = super().to_representation(instance) + + # Convert string integers back to integers + if data.get('height'): + try: + data['height'] = int(data['height']) + except (ValueError, TypeError): + data['height'] = None + + if data.get('fee'): + try: + data['fee'] = int(data['fee']) + except (ValueError, TypeError): + data['fee'] = None + + if data.get('value'): + try: + data['value'] = int(data['value']) + except (ValueError, TypeError): + data['value'] = None + + # Convert JSON strings back to objects + if data.get('rate'): + try: + data['rate'] = json.loads(data['rate']) + except (json.JSONDecodeError, TypeError): + data['rate'] = None + + if data.get('fmv'): + try: + data['fmv'] = json.loads(data['fmv']) + except (json.JSONDecodeError, TypeError): + data['fmv'] = None + + if data.get('heights'): + try: + data['heights'] = json.loads(data['heights']) + except (json.JSONDecodeError, TypeError): + data['heights'] = None + + return data + + class LabelbaseSerializer(serializers.ModelSerializer): def __init__(self, *args, **kwargs): super(LabelbaseSerializer, self).__init__(*args, **kwargs) diff --git a/django/labelbase/static/Cloud.png b/django/labelbase/static/Cloud.png new file mode 100644 index 0000000..5882b4a Binary files /dev/null and b/django/labelbase/static/Cloud.png differ diff --git a/django/labelbase/static/js/bootstrap.bundle.min.js b/django/labelbase/static/js/bootstrap.bundle.min.js new file mode 100644 index 0000000..da8d852 --- /dev/null +++ b/django/labelbase/static/js/bootstrap.bundle.min.js @@ -0,0 +1,7 @@ +/*! + * Bootstrap v5.3.0-alpha1 (https://getbootstrap.com/) + * Copyright 2011-2022 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t="transitionend",e=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),i=e=>{e.dispatchEvent(new Event(t))},n=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),s=t=>n(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(e(t)):null,o=t=>{if(!n(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},r=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),a=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?a(t.parentNode):null},l=()=>{},c=t=>{t.offsetHeight},h=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,d=[],u=()=>"rtl"===document.documentElement.dir,f=t=>{var e;e=()=>{const e=h();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(d.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of d)t()})),d.push(e)):e()},p=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,m=(e,n,s=!0)=>{if(!s)return void p(e);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(n)+5;let r=!1;const a=({target:i})=>{i===n&&(r=!0,n.removeEventListener(t,a),p(e))};n.addEventListener(t,a),setTimeout((()=>{r||i(n)}),o)},g=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},_=/[^.]*(?=\..*)\.|.*/,b=/\..*/,v=/::\d+$/,y={};let w=1;const A={mouseenter:"mouseover",mouseleave:"mouseout"},E=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function T(t,e){return e&&`${e}::${w++}`||t.uidEvent||w++}function C(t){const e=T(t);return t.uidEvent=e,y[e]=y[e]||{},y[e]}function O(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function x(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=D(t);return E.has(o)||(o=t),[n,s,o]}function k(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=x(e,i,n);if(e in A){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=C(t),c=l[a]||(l[a]={}),h=O(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=T(r,e.replace(_,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return N(s,{delegateTarget:r}),n.oneOff&&I.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return N(n,{delegateTarget:t}),i.oneOff&&I.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function L(t,e,i,n,s){const o=O(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function S(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&L(t,e,i,r.callable,r.delegationSelector)}function D(t){return t=t.replace(b,""),A[t]||t}const I={on(t,e,i,n){k(t,e,i,n,!1)},one(t,e,i,n){k(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=x(e,i,n),a=r!==e,l=C(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))S(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(v,"");a&&!e.includes(s)||L(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;L(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=h();let s=null,o=!0,r=!0,a=!1;e!==D(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());let l=new Event(e,{bubbles:o,cancelable:!0});return l=N(l,i),a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function N(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}const P=new Map,j={set(t,e,i){P.has(t)||P.set(t,new Map);const n=P.get(t);n.has(e)||0===n.size?n.set(e,i):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(n.keys())[0]}.`)},get:(t,e)=>P.has(t)&&P.get(t).get(e)||null,remove(t,e){if(!P.has(t))return;const i=P.get(t);i.delete(e),0===i.size&&P.delete(t)}};function M(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function F(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const H={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${F(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${F(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=M(t.dataset[n])}return e},getDataAttribute:(t,e)=>M(t.getAttribute(`data-bs-${F(e)}`))};class ${static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=n(e)?H.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...n(e)?H.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[s,o]of Object.entries(e)){const e=t[s],r=n(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(o).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${s}" provided type "${r}" but expected type "${o}".`)}var i}}class W extends ${constructor(t,e){super(),(t=s(t))&&(this._element=t,this._config=this._getConfig(e),j.set(this._element,this.constructor.DATA_KEY,this))}dispose(){j.remove(this._element,this.constructor.DATA_KEY),I.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){m(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return j.get(s(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.0-alpha1"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let i=t.getAttribute("data-bs-target");if(!i||"#"===i){let e=t.getAttribute("href");if(!e||!e.includes("#")&&!e.startsWith("."))return null;e.includes("#")&&!e.startsWith("#")&&(e=`#${e.split("#")[1]}`),i=e&&"#"!==e?e.trim():null}return e(i)},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!r(t)&&o(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;I.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),r(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))};class q extends W{static get NAME(){return"alert"}close(){if(I.trigger(this._element,"close.bs.alert").defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),I.trigger(this._element,"closed.bs.alert"),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(q,"close"),f(q);const V='[data-bs-toggle="button"]';class K extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=K.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}I.on(document,"click.bs.button.data-api",V,(t=>{t.preventDefault();const e=t.target.closest(V);K.getOrCreateInstance(e).toggle()})),f(K);const Q={endCallback:null,leftCallback:null,rightCallback:null},X={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class Y extends ${constructor(t,e){super(),this._element=t,t&&Y.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return Q}static get DefaultType(){return X}static get NAME(){return"swipe"}dispose(){I.off(this._element,".bs.swipe")}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),p(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&p(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(I.on(this._element,"pointerdown.bs.swipe",(t=>this._start(t))),I.on(this._element,"pointerup.bs.swipe",(t=>this._end(t))),this._element.classList.add("pointer-event")):(I.on(this._element,"touchstart.bs.swipe",(t=>this._start(t))),I.on(this._element,"touchmove.bs.swipe",(t=>this._move(t))),I.on(this._element,"touchend.bs.swipe",(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const U="next",G="prev",J="left",Z="right",tt="slid.bs.carousel",et="carousel",it="active",nt={ArrowLeft:Z,ArrowRight:J},st={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},ot={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class rt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===et&&this.cycle()}static get Default(){return st}static get DefaultType(){return ot}static get NAME(){return"carousel"}next(){this._slide(U)}nextWhenVisible(){!document.hidden&&o(this._element)&&this.next()}prev(){this._slide(G)}pause(){this._isSliding&&i(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?I.one(this._element,tt,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void I.one(this._element,tt,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?U:G;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&I.on(this._element,"keydown.bs.carousel",(t=>this._keydown(t))),"hover"===this._config.pause&&(I.on(this._element,"mouseenter.bs.carousel",(()=>this.pause())),I.on(this._element,"mouseleave.bs.carousel",(()=>this._maybeEnableCycle()))),this._config.touch&&Y.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))I.on(t,"dragstart.bs.carousel",(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(J)),rightCallback:()=>this._slide(this._directionToOrder(Z)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new Y(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=nt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(".active",this._indicatorsElement);e.classList.remove(it),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(it),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===U,s=e||g(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>I.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r("slide.bs.carousel").defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",h=n?"carousel-item-next":"carousel-item-prev";s.classList.add(h),c(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,h),s.classList.add(it),i.classList.remove(it,h,l),this._isSliding=!1,r(tt)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(".active.carousel-item",this._element)}_getItems(){return z.find(".carousel-item",this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return u()?t===J?G:U:t===J?U:G}_orderToDirection(t){return u()?t===G?J:Z:t===G?Z:J}static jQueryInterface(t){return this.each((function(){const e=rt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}I.on(document,"click.bs.carousel.data-api","[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(et))return;t.preventDefault();const i=rt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===H.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),I.on(window,"load.bs.carousel.data-api",(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)rt.getOrCreateInstance(e)})),f(rt);const at="show",lt="collapse",ct="collapsing",ht='[data-bs-toggle="collapse"]',dt={parent:null,toggle:!0},ut={parent:"(null|element)",toggle:"boolean"};class ft extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(ht);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return dt}static get DefaultType(){return ut}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>ft.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(I.trigger(this._element,"show.bs.collapse").defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(lt),this._element.classList.add(ct),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ct),this._element.classList.add(lt,at),this._element.style[e]="",I.trigger(this._element,"shown.bs.collapse")}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(I.trigger(this._element,"hide.bs.collapse").defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,c(this._element),this._element.classList.add(ct),this._element.classList.remove(lt,at);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ct),this._element.classList.add(lt),I.trigger(this._element,"hidden.bs.collapse")}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(at)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=s(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(ht);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(":scope .collapse .collapse",this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=ft.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}I.on(document,"click.bs.collapse.data-api",ht,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))ft.getOrCreateInstance(t,{toggle:!1}).toggle()})),f(ft);var pt="top",mt="bottom",gt="right",_t="left",bt="auto",vt=[pt,mt,gt,_t],yt="start",wt="end",At="clippingParents",Et="viewport",Tt="popper",Ct="reference",Ot=vt.reduce((function(t,e){return t.concat([e+"-"+yt,e+"-"+wt])}),[]),xt=[].concat(vt,[bt]).reduce((function(t,e){return t.concat([e,e+"-"+yt,e+"-"+wt])}),[]),kt="beforeRead",Lt="read",St="afterRead",Dt="beforeMain",It="main",Nt="afterMain",Pt="beforeWrite",jt="write",Mt="afterWrite",Ft=[kt,Lt,St,Dt,It,Nt,Pt,jt,Mt];function Ht(t){return t?(t.nodeName||"").toLowerCase():null}function $t(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function Wt(t){return t instanceof $t(t).Element||t instanceof Element}function Bt(t){return t instanceof $t(t).HTMLElement||t instanceof HTMLElement}function zt(t){return"undefined"!=typeof ShadowRoot&&(t instanceof $t(t).ShadowRoot||t instanceof ShadowRoot)}const Rt={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];Bt(s)&&Ht(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});Bt(n)&&Ht(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function qt(t){return t.split("-")[0]}var Vt=Math.max,Kt=Math.min,Qt=Math.round;function Xt(){var t=navigator.userAgentData;return null!=t&&t.brands?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Yt(){return!/^((?!chrome|android).)*safari/i.test(Xt())}function Ut(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&Bt(t)&&(s=t.offsetWidth>0&&Qt(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&Qt(n.height)/t.offsetHeight||1);var r=(Wt(t)?$t(t):window).visualViewport,a=!Yt()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Gt(t){var e=Ut(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Jt(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&zt(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function Zt(t){return $t(t).getComputedStyle(t)}function te(t){return["table","td","th"].indexOf(Ht(t))>=0}function ee(t){return((Wt(t)?t.ownerDocument:t.document)||window.document).documentElement}function ie(t){return"html"===Ht(t)?t:t.assignedSlot||t.parentNode||(zt(t)?t.host:null)||ee(t)}function ne(t){return Bt(t)&&"fixed"!==Zt(t).position?t.offsetParent:null}function se(t){for(var e=$t(t),i=ne(t);i&&te(i)&&"static"===Zt(i).position;)i=ne(i);return i&&("html"===Ht(i)||"body"===Ht(i)&&"static"===Zt(i).position)?e:i||function(t){var e=/firefox/i.test(Xt());if(/Trident/i.test(Xt())&&Bt(t)&&"fixed"===Zt(t).position)return null;var i=ie(t);for(zt(i)&&(i=i.host);Bt(i)&&["html","body"].indexOf(Ht(i))<0;){var n=Zt(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function oe(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function re(t,e,i){return Vt(t,Kt(e,i))}function ae(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function le(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const ce={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=qt(i.placement),l=oe(a),c=[_t,gt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return ae("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:le(t,vt))}(s.padding,i),d=Gt(o),u="y"===l?pt:_t,f="y"===l?mt:gt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=se(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=re(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Jt(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function he(t){return t.split("-")[1]}var de={top:"auto",right:"auto",bottom:"auto",left:"auto"};function ue(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=_t,y=pt,w=window;if(c){var A=se(i),E="clientHeight",T="clientWidth";A===$t(i)&&"static"!==Zt(A=ee(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===pt||(s===_t||s===gt)&&o===wt)&&(y=mt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==_t&&(s!==pt&&s!==mt||o!==wt)||(v=gt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&de),x=!0===h?function(t){var e=t.x,i=t.y,n=window.devicePixelRatio||1;return{x:Qt(e*n)/n||0,y:Qt(i*n)/n||0}}({x:f,y:m}):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const fe={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:qt(e.placement),variation:he(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,ue(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,ue(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var pe={passive:!0};const me={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=$t(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,pe)})),a&&l.addEventListener("resize",i.update,pe),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,pe)})),a&&l.removeEventListener("resize",i.update,pe)}},data:{}};var ge={left:"right",right:"left",bottom:"top",top:"bottom"};function _e(t){return t.replace(/left|right|bottom|top/g,(function(t){return ge[t]}))}var be={start:"end",end:"start"};function ve(t){return t.replace(/start|end/g,(function(t){return be[t]}))}function ye(t){var e=$t(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function we(t){return Ut(ee(t)).left+ye(t).scrollLeft}function Ae(t){var e=Zt(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ee(t){return["html","body","#document"].indexOf(Ht(t))>=0?t.ownerDocument.body:Bt(t)&&Ae(t)?t:Ee(ie(t))}function Te(t,e){var i;void 0===e&&(e=[]);var n=Ee(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=$t(n),r=s?[o].concat(o.visualViewport||[],Ae(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Te(ie(r)))}function Ce(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function Oe(t,e,i){return e===Et?Ce(function(t,e){var i=$t(t),n=ee(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Yt();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+we(t),y:l}}(t,i)):Wt(e)?function(t,e){var i=Ut(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Ce(function(t){var e,i=ee(t),n=ye(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=Vt(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=Vt(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+we(t),l=-n.scrollTop;return"rtl"===Zt(s||i).direction&&(a+=Vt(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(ee(t)))}function xe(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?qt(s):null,r=s?he(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case pt:e={x:a,y:i.y-n.height};break;case mt:e={x:a,y:i.y+i.height};break;case gt:e={x:i.x+i.width,y:l};break;case _t:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?oe(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case yt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case wt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ke(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?At:a,c=i.rootBoundary,h=void 0===c?Et:c,d=i.elementContext,u=void 0===d?Tt:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=ae("number"!=typeof g?g:le(g,vt)),b=u===Tt?Ct:Tt,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Te(ie(t)),i=["absolute","fixed"].indexOf(Zt(t).position)>=0&&Bt(t)?se(t):t;return Wt(i)?e.filter((function(t){return Wt(t)&&Jt(t,i)&&"body"!==Ht(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=Oe(t,i,n);return e.top=Vt(s.top,e.top),e.right=Kt(s.right,e.right),e.bottom=Kt(s.bottom,e.bottom),e.left=Vt(s.left,e.left),e}),Oe(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(Wt(y)?y:y.contextElement||ee(t.elements.popper),l,h,r),A=Ut(t.elements.reference),E=xe({reference:A,element:v,strategy:"absolute",placement:s}),T=Ce(Object.assign({},v,E)),C=u===Tt?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Tt&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[gt,mt].indexOf(t)>=0?1:-1,i=[pt,mt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function Le(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?xt:l,h=he(n),d=h?a?Ot:Ot.filter((function(t){return he(t)===h})):vt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ke(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[qt(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const Se={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=qt(g),b=l||(_!==g&&p?function(t){if(qt(t)===bt)return[];var e=_e(t);return[ve(t),e,ve(e)]}(g):[_e(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(qt(i)===bt?Le(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=ke(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),I=L?k?gt:_t:k?mt:pt;y[S]>w[S]&&(I=_e(I));var N=_e(I),P=[];if(o&&P.push(D[x]<=0),a&&P.push(D[I]<=0,D[N]<=0),P.every((function(t){return t}))){T=O,E=!1;break}A.set(O,P)}if(E)for(var j=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},M=p?3:1;M>0&&"break"!==j(M);M--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function De(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function Ie(t){return[pt,gt,mt,_t].some((function(e){return t[e]>=0}))}const Ne={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ke(e,{elementContext:"reference"}),a=ke(e,{altBoundary:!0}),l=De(r,n),c=De(a,s,o),h=Ie(l),d=Ie(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},Pe={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=xt.reduce((function(t,i){return t[i]=function(t,e,i){var n=qt(t),s=[_t,pt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[_t,gt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},je={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=xe({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},Me={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ke(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=qt(e.placement),b=he(e.placement),v=!b,y=oe(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?pt:_t,D="y"===y?mt:gt,I="y"===y?"height":"width",N=A[y],P=N+g[S],j=N-g[D],M=f?-T[I]/2:0,F=b===yt?E[I]:T[I],H=b===yt?-T[I]:-E[I],$=e.elements.arrow,W=f&&$?Gt($):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=re(0,E[I],W[I]),V=v?E[I]/2-M-q-z-O.mainAxis:F-q-z-O.mainAxis,K=v?-E[I]/2+M+q+R+O.mainAxis:H+q+R+O.mainAxis,Q=e.elements.arrow&&se(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=N+K-Y,G=re(f?Kt(P,N+V-Y-X):P,N,f?Vt(j,U):j);A[y]=G,k[y]=G-N}if(a){var J,Z="x"===y?pt:_t,tt="x"===y?mt:gt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[pt,_t].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=re(t,e,i);return n>i?i:n}(at,et,lt):re(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function Fe(t,e,i){void 0===i&&(i=!1);var n,s,o=Bt(e),r=Bt(e)&&function(t){var e=t.getBoundingClientRect(),i=Qt(e.width)/t.offsetWidth||1,n=Qt(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=ee(e),l=Ut(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==Ht(e)||Ae(a))&&(c=(n=e)!==$t(n)&&Bt(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:ye(n)),Bt(e)?((h=Ut(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=we(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function He(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var $e={placement:"bottom",modifiers:[],strategy:"absolute"};function We(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(H.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...p(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>o(t)));i.length&&g(i,e,t===Xe,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=ci.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(Ze);for(const i of e){const e=ci.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Qe,Xe].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Je)?this:z.prev(this,Je)[0]||z.next(this,Je)[0]||z.findOne(Je,t.delegateTarget.parentNode),o=ci.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}I.on(document,Ue,Je,ci.dataApiKeydownHandler),I.on(document,Ue,ti,ci.dataApiKeydownHandler),I.on(document,Ye,ci.clearMenus),I.on(document,"keyup.bs.dropdown.data-api",ci.clearMenus),I.on(document,Ye,Je,(function(t){t.preventDefault(),ci.getOrCreateInstance(this).toggle()})),f(ci);const hi=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",di=".sticky-top",ui="padding-right",fi="margin-right";class pi{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,ui,(e=>e+t)),this._setElementAttributes(hi,ui,(e=>e+t)),this._setElementAttributes(di,fi,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,ui),this._resetElementAttributes(hi,ui),this._resetElementAttributes(di,fi)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&H.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=H.getDataAttribute(t,e);null!==i?(H.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(n(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const mi="show",gi="mousedown.bs.backdrop",_i={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},bi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class vi extends ${constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return _i}static get DefaultType(){return bi}static get NAME(){return"backdrop"}show(t){if(!this._config.isVisible)return void p(t);this._append();const e=this._getElement();this._config.isAnimated&&c(e),e.classList.add(mi),this._emulateAnimation((()=>{p(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(mi),this._emulateAnimation((()=>{this.dispose(),p(t)}))):p(t)}dispose(){this._isAppended&&(I.off(this._element,gi),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=s(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),I.on(t,gi,(()=>{p(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){m(t,this._getElement(),this._config.isAnimated)}}const yi=".bs.focustrap",wi="backward",Ai={autofocus:!0,trapElement:null},Ei={autofocus:"boolean",trapElement:"element"};class Ti extends ${constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return Ai}static get DefaultType(){return Ei}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),I.off(document,yi),I.on(document,"focusin.bs.focustrap",(t=>this._handleFocusin(t))),I.on(document,"keydown.tab.bs.focustrap",(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,I.off(document,yi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===wi?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?wi:"forward")}}const Ci="hidden.bs.modal",Oi="show.bs.modal",xi="modal-open",ki="show",Li="modal-static",Si={backdrop:!0,focus:!0,keyboard:!0},Di={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Ii extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new pi,this._addEventListeners()}static get Default(){return Si}static get DefaultType(){return Di}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||I.trigger(this._element,Oi,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(xi),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(I.trigger(this._element,"hide.bs.modal").defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(ki),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){for(const t of[window,this._dialog])I.off(t,".bs.modal");this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new vi({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new Ti({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),c(this._element),this._element.classList.add(ki),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,I.trigger(this._element,"shown.bs.modal",{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){I.on(this._element,"keydown.dismiss.bs.modal",(t=>{if("Escape"===t.key)return this._config.keyboard?(t.preventDefault(),void this.hide()):void this._triggerBackdropTransition()})),I.on(window,"resize.bs.modal",(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),I.on(this._element,"mousedown.dismiss.bs.modal",(t=>{I.one(this._element,"click.dismiss.bs.modal",(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(xi),this._resetAdjustments(),this._scrollBar.reset(),I.trigger(this._element,Ci)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(I.trigger(this._element,"hidePrevented.bs.modal").defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(Li)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(Li),this._queueCallback((()=>{this._element.classList.remove(Li),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=u()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=u()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Ii.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}I.on(document,"click.bs.modal.data-api",'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),I.one(e,Oi,(t=>{t.defaultPrevented||I.one(e,Ci,(()=>{o(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&Ii.getInstance(i).hide(),Ii.getOrCreateInstance(e).toggle(this)})),R(Ii),f(Ii);const Ni="show",Pi="showing",ji="hiding",Mi=".offcanvas.show",Fi="hidePrevented.bs.offcanvas",Hi="hidden.bs.offcanvas",$i={backdrop:!0,keyboard:!0,scroll:!1},Wi={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class Bi extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return $i}static get DefaultType(){return Wi}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||I.trigger(this._element,"show.bs.offcanvas",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new pi).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Pi),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Ni),this._element.classList.remove(Pi),I.trigger(this._element,"shown.bs.offcanvas",{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(I.trigger(this._element,"hide.bs.offcanvas").defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(ji),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Ni,ji),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new pi).reset(),I.trigger(this._element,Hi)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new vi({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():I.trigger(this._element,Fi)}:null})}_initializeFocusTrap(){return new Ti({trapElement:this._element})}_addEventListeners(){I.on(this._element,"keydown.dismiss.bs.offcanvas",(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():I.trigger(this._element,Fi))}))}static jQueryInterface(t){return this.each((function(){const e=Bi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}I.on(document,"click.bs.offcanvas.data-api",'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),r(this))return;I.one(e,Hi,(()=>{o(this)&&this.focus()}));const i=z.findOne(Mi);i&&i!==e&&Bi.getInstance(i).hide(),Bi.getOrCreateInstance(e).toggle(this)})),I.on(window,"load.bs.offcanvas.data-api",(()=>{for(const t of z.find(Mi))Bi.getOrCreateInstance(t).show()})),I.on(window,"resize.bs.offcanvas",(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&Bi.getOrCreateInstance(t).hide()})),R(Bi),f(Bi);const zi=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Ri=/^(?:(?:https?|mailto|ftp|tel|file|sms):|[^#&/:?]*(?:[#/?]|$))/i,qi=/^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[\d+/a-z]+=*$/i,Vi=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!zi.has(i)||Boolean(Ri.test(t.nodeValue)||qi.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Ki={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Qi={allowList:Ki,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Xi={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Yi={entry:"(string|element|function|null)",selector:"(string|element)"};class Ui extends ${constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Qi}static get DefaultType(){return Xi}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Yi)}_setContent(t,e,i){const o=z.findOne(i,t);o&&((e=this._resolvePossibleFunction(e))?n(e)?this._putElementInTemplate(s(e),o):this._config.html?o.innerHTML=this._maybeSanitize(e):o.textContent=e:o.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Vi(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return p(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Gi=new Set(["sanitize","allowList","sanitizeFn"]),Ji="fade",Zi="show",tn=".modal",en="hide.bs.modal",nn="hover",sn="focus",on={AUTO:"auto",TOP:"top",RIGHT:u()?"left":"right",BOTTOM:"bottom",LEFT:u()?"right":"left"},rn={allowList:Ki,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,0],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},an={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class ln extends W{constructor(t,e){if(void 0===Ve)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return rn}static get DefaultType(){return an}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),I.off(this._element.closest(tn),en,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=I.trigger(this._element,this.constructor.eventName("show")),e=(a(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),I.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(Zi),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))I.on(t,"mouseover",l);this._queueCallback((()=>{I.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!I.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(Zi),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))I.off(t,"mouseover",l);this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),I.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(Ji,Zi),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(Ji),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Ui({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ji)}_isShown(){return this.tip&&this.tip.classList.contains(Zi)}_createPopper(t){const e=p(this._config.placement,[this,t,this._element]),i=on[e.toUpperCase()];return qe(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return p(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...p(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)I.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===nn?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===nn?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");I.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?sn:nn]=!0,e._enter()})),I.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?sn:nn]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},I.on(this._element.closest(tn),en,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=H.getDataAttributes(this._element);for(const t of Object.keys(e))Gi.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:s(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=ln.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}f(ln);const cn={...ln.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},hn={...ln.DefaultType,content:"(null|string|element|function)"};class dn extends ln{static get Default(){return cn}static get DefaultType(){return hn}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=dn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}f(dn);const un="click.bs.scrollspy",fn="active",pn="[href]",mn={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},gn={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class _n extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return mn}static get DefaultType(){return gn}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=s(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(I.off(this._config.target,un),I.on(this._config.target,un,pn,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(pn,this._config.target);for(const e of t){if(!e.hash||r(e))continue;const t=z.findOne(e.hash,this._element);o(t)&&(this._targetLinks.set(e.hash,e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(fn),this._activateParents(t),I.trigger(this._element,"activate.bs.scrollspy",{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(fn);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,".nav-link, .nav-item > .nav-link, .list-group-item"))t.classList.add(fn)}_clearActiveClass(t){t.classList.remove(fn);const e=z.find("[href].active",t);for(const t of e)t.classList.remove(fn)}static jQueryInterface(t){return this.each((function(){const e=_n.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}I.on(window,"load.bs.scrollspy.data-api",(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))_n.getOrCreateInstance(t)})),f(_n);const bn="ArrowLeft",vn="ArrowRight",yn="ArrowUp",wn="ArrowDown",An="active",En="fade",Tn="show",Cn='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',On=`.nav-link:not(.dropdown-toggle), .list-group-item:not(.dropdown-toggle), [role="tab"]:not(.dropdown-toggle), ${Cn}`;class xn extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),I.on(this._element,"keydown.bs.tab",(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?I.trigger(e,"hide.bs.tab",{relatedTarget:t}):null;I.trigger(t,"show.bs.tab",{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(An),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),I.trigger(t,"shown.bs.tab",{relatedTarget:e})):t.classList.add(Tn)}),t,t.classList.contains(En)))}_deactivate(t,e){t&&(t.classList.remove(An),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),I.trigger(t,"hidden.bs.tab",{relatedTarget:e})):t.classList.remove(Tn)}),t,t.classList.contains(En)))}_keydown(t){if(![bn,vn,yn,wn].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=[vn,wn].includes(t.key),i=g(this._getChildren().filter((t=>!r(t))),t.target,e,!0);i&&(i.focus({preventScroll:!0}),xn.getOrCreateInstance(i).show())}_getChildren(){return z.find(On,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`#${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(".dropdown-toggle",An),n(".dropdown-menu",Tn),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(An)}_getInnerElement(t){return t.matches(On)?t:z.findOne(On,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=xn.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}I.on(document,"click.bs.tab",Cn,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),r(this)||xn.getOrCreateInstance(this).show()})),I.on(window,"load.bs.tab",(()=>{for(const t of z.find('.active[data-bs-toggle="tab"], .active[data-bs-toggle="pill"], .active[data-bs-toggle="list"]'))xn.getOrCreateInstance(t)})),f(xn);const kn="hide",Ln="show",Sn="showing",Dn={animation:"boolean",autohide:"boolean",delay:"number"},In={animation:!0,autohide:!0,delay:5e3};class Nn extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return In}static get DefaultType(){return Dn}static get NAME(){return"toast"}show(){I.trigger(this._element,"show.bs.toast").defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(kn),c(this._element),this._element.classList.add(Ln,Sn),this._queueCallback((()=>{this._element.classList.remove(Sn),I.trigger(this._element,"shown.bs.toast"),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(I.trigger(this._element,"hide.bs.toast").defaultPrevented||(this._element.classList.add(Sn),this._queueCallback((()=>{this._element.classList.add(kn),this._element.classList.remove(Sn,Ln),I.trigger(this._element,"hidden.bs.toast")}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(Ln),super.dispose()}isShown(){return this._element.classList.contains(Ln)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){I.on(this._element,"mouseover.bs.toast",(t=>this._onInteraction(t,!0))),I.on(this._element,"mouseout.bs.toast",(t=>this._onInteraction(t,!1))),I.on(this._element,"focusin.bs.toast",(t=>this._onInteraction(t,!0))),I.on(this._element,"focusout.bs.toast",(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Nn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(Nn),f(Nn),{Alert:q,Button:K,Carousel:rt,Collapse:ft,Dropdown:ci,Modal:Ii,Offcanvas:Bi,Popover:dn,ScrollSpy:_n,Tab:xn,Toast:Nn,Tooltip:ln}})); +//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/django/labelbase/static/js/feather.min.js b/django/labelbase/static/js/feather.min.js new file mode 100644 index 0000000..156cd61 --- /dev/null +++ b/django/labelbase/static/js/feather.min.js @@ -0,0 +1,13 @@ +!function(e,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.feather=n():e.feather=n()}("undefined"!=typeof self?self:this,function(){return function(e){var n={};function i(t){if(n[t])return n[t].exports;var l=n[t]={i:t,l:!1,exports:{}};return e[t].call(l.exports,l,l.exports,i),l.l=!0,l.exports}return i.m=e,i.c=n,i.d=function(e,n,t){i.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:t})},i.r=function(e){Object.defineProperty(e,"__esModule",{value:!0})},i.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return i.d(n,"a",n),n},i.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},i.p="",i(i.s=80)}([function(e,n,i){(function(n){var i="object",t=function(e){return e&&e.Math==Math&&e};e.exports=t(typeof globalThis==i&&globalThis)||t(typeof window==i&&window)||t(typeof self==i&&self)||t(typeof n==i&&n)||Function("return this")()}).call(this,i(75))},function(e,n){var i={}.hasOwnProperty;e.exports=function(e,n){return i.call(e,n)}},function(e,n,i){var t=i(0),l=i(11),r=i(33),o=i(62),a=t.Symbol,c=l("wks");e.exports=function(e){return c[e]||(c[e]=o&&a[e]||(o?a:r)("Symbol."+e))}},function(e,n,i){var t=i(6);e.exports=function(e){if(!t(e))throw TypeError(String(e)+" is not an object");return e}},function(e,n){e.exports=function(e){try{return!!e()}catch(e){return!0}}},function(e,n,i){var t=i(8),l=i(7),r=i(10);e.exports=t?function(e,n,i){return l.f(e,n,r(1,i))}:function(e,n,i){return e[n]=i,e}},function(e,n){e.exports=function(e){return"object"==typeof e?null!==e:"function"==typeof e}},function(e,n,i){var t=i(8),l=i(35),r=i(3),o=i(18),a=Object.defineProperty;n.f=t?a:function(e,n,i){if(r(e),n=o(n,!0),r(i),l)try{return a(e,n,i)}catch(e){}if("get"in i||"set"in i)throw TypeError("Accessors not supported");return"value"in i&&(e[n]=i.value),e}},function(e,n,i){var t=i(4);e.exports=!t(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(e,n){e.exports={}},function(e,n){e.exports=function(e,n){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:n}}},function(e,n,i){var t=i(0),l=i(19),r=i(17),o=t["__core-js_shared__"]||l("__core-js_shared__",{});(e.exports=function(e,n){return o[e]||(o[e]=void 0!==n?n:{})})("versions",[]).push({version:"3.1.3",mode:r?"pure":"global",copyright:"© 2019 Denis Pushkarev (zloirock.ru)"})},function(e,n,i){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var t=o(i(43)),l=o(i(41)),r=o(i(40));function o(e){return e&&e.__esModule?e:{default:e}}n.default=Object.keys(l.default).map(function(e){return new t.default(e,l.default[e],r.default[e])}).reduce(function(e,n){return e[n.name]=n,e},{})},function(e,n){e.exports=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"]},function(e,n,i){var t=i(72),l=i(20);e.exports=function(e){return t(l(e))}},function(e,n){e.exports={}},function(e,n,i){var t=i(11),l=i(33),r=t("keys");e.exports=function(e){return r[e]||(r[e]=l(e))}},function(e,n){e.exports=!1},function(e,n,i){var t=i(6);e.exports=function(e,n){if(!t(e))return e;var i,l;if(n&&"function"==typeof(i=e.toString)&&!t(l=i.call(e)))return l;if("function"==typeof(i=e.valueOf)&&!t(l=i.call(e)))return l;if(!n&&"function"==typeof(i=e.toString)&&!t(l=i.call(e)))return l;throw TypeError("Can't convert object to primitive value")}},function(e,n,i){var t=i(0),l=i(5);e.exports=function(e,n){try{l(t,e,n)}catch(i){t[e]=n}return n}},function(e,n){e.exports=function(e){if(void 0==e)throw TypeError("Can't call method on "+e);return e}},function(e,n){var i=Math.ceil,t=Math.floor;e.exports=function(e){return isNaN(e=+e)?0:(e>0?t:i)(e)}},function(e,n,i){var t; +/*! + Copyright (c) 2016 Jed Watson. + Licensed under the MIT License (MIT), see + http://jedwatson.github.io/classnames +*/ +/*! + Copyright (c) 2016 Jed Watson. + Licensed under the MIT License (MIT), see + http://jedwatson.github.io/classnames +*/ +!function(){"use strict";var i=function(){function e(){}function n(e,n){for(var i=n.length,t=0;t0?l(t(e),9007199254740991):0}},function(e,n,i){var t=i(1),l=i(14),r=i(68),o=i(15),a=r(!1);e.exports=function(e,n){var i,r=l(e),c=0,p=[];for(i in r)!t(o,i)&&t(r,i)&&p.push(i);for(;n.length>c;)t(r,i=n[c++])&&(~a(p,i)||p.push(i));return p}},function(e,n,i){var t=i(0),l=i(11),r=i(5),o=i(1),a=i(19),c=i(36),p=i(37),y=p.get,h=p.enforce,x=String(c).split("toString");l("inspectSource",function(e){return c.call(e)}),(e.exports=function(e,n,i,l){var c=!!l&&!!l.unsafe,p=!!l&&!!l.enumerable,y=!!l&&!!l.noTargetGet;"function"==typeof i&&("string"!=typeof n||o(i,"name")||r(i,"name",n),h(i).source=x.join("string"==typeof n?n:"")),e!==t?(c?!y&&e[n]&&(p=!0):delete e[n],p?e[n]=i:r(e,n,i)):p?e[n]=i:a(n,i)})(Function.prototype,"toString",function(){return"function"==typeof this&&y(this).source||c.call(this)})},function(e,n){var i={}.toString;e.exports=function(e){return i.call(e).slice(8,-1)}},function(e,n,i){var t=i(8),l=i(73),r=i(10),o=i(14),a=i(18),c=i(1),p=i(35),y=Object.getOwnPropertyDescriptor;n.f=t?y:function(e,n){if(e=o(e),n=a(n,!0),p)try{return y(e,n)}catch(e){}if(c(e,n))return r(!l.f.call(e,n),e[n])}},function(e,n,i){var t=i(0),l=i(31).f,r=i(5),o=i(29),a=i(19),c=i(71),p=i(65);e.exports=function(e,n){var i,y,h,x,s,u=e.target,d=e.global,f=e.stat;if(i=d?t:f?t[u]||a(u,{}):(t[u]||{}).prototype)for(y in n){if(x=n[y],h=e.noTargetGet?(s=l(i,y))&&s.value:i[y],!p(d?y:u+(f?".":"#")+y,e.forced)&&void 0!==h){if(typeof x==typeof h)continue;c(x,h)}(e.sham||h&&h.sham)&&r(x,"sham",!0),o(i,y,x,e)}}},function(e,n){var i=0,t=Math.random();e.exports=function(e){return"Symbol(".concat(void 0===e?"":e,")_",(++i+t).toString(36))}},function(e,n,i){var t=i(0),l=i(6),r=t.document,o=l(r)&&l(r.createElement);e.exports=function(e){return o?r.createElement(e):{}}},function(e,n,i){var t=i(8),l=i(4),r=i(34);e.exports=!t&&!l(function(){return 7!=Object.defineProperty(r("div"),"a",{get:function(){return 7}}).a})},function(e,n,i){var t=i(11);e.exports=t("native-function-to-string",Function.toString)},function(e,n,i){var t,l,r,o=i(76),a=i(0),c=i(6),p=i(5),y=i(1),h=i(16),x=i(15),s=a.WeakMap;if(o){var u=new s,d=u.get,f=u.has,g=u.set;t=function(e,n){return g.call(u,e,n),n},l=function(e){return d.call(u,e)||{}},r=function(e){return f.call(u,e)}}else{var v=h("state");x[v]=!0,t=function(e,n){return p(e,v,n),n},l=function(e){return y(e,v)?e[v]:{}},r=function(e){return y(e,v)}}e.exports={set:t,get:l,has:r,enforce:function(e){return r(e)?l(e):t(e,{})},getterFor:function(e){return function(n){var i;if(!c(n)||(i=l(n)).type!==e)throw TypeError("Incompatible receiver, "+e+" required");return i}}}},function(e,n,i){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var t=Object.assign||function(e){for(var n=1;n0&&void 0!==arguments[0]?arguments[0]:{};if("undefined"==typeof document)throw new Error("`feather.replace()` only works in a browser environment.");var n=document.querySelectorAll("[data-feather]");Array.from(n).forEach(function(n){return function(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=function(e){return Array.from(e.attributes).reduce(function(e,n){return e[n.name]=n.value,e},{})}(e),o=i["data-feather"];delete i["data-feather"];var a=r.default[o].toSvg(t({},n,i,{class:(0,l.default)(n.class,i.class)})),c=(new DOMParser).parseFromString(a,"image/svg+xml").querySelector("svg");e.parentNode.replaceChild(c,e)}(n,e)})}},function(e,n,i){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var t,l=i(12),r=(t=l)&&t.__esModule?t:{default:t};n.default=function(e){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(console.warn("feather.toSvg() is deprecated. Please use feather.icons[name].toSvg() instead."),!e)throw new Error("The required `key` (icon name) parameter is missing.");if(!r.default[e])throw new Error("No icon matching '"+e+"'. See the complete list of icons at https://feathericons.com");return r.default[e].toSvg(n)}},function(e){e.exports={activity:["pulse","health","action","motion"],airplay:["stream","cast","mirroring"],"alert-circle":["warning","alert","danger"],"alert-octagon":["warning","alert","danger"],"alert-triangle":["warning","alert","danger"],"align-center":["text alignment","center"],"align-justify":["text alignment","justified"],"align-left":["text alignment","left"],"align-right":["text alignment","right"],anchor:[],archive:["index","box"],"at-sign":["mention","at","email","message"],award:["achievement","badge"],aperture:["camera","photo"],"bar-chart":["statistics","diagram","graph"],"bar-chart-2":["statistics","diagram","graph"],battery:["power","electricity"],"battery-charging":["power","electricity"],bell:["alarm","notification","sound"],"bell-off":["alarm","notification","silent"],bluetooth:["wireless"],"book-open":["read","library"],book:["read","dictionary","booklet","magazine","library"],bookmark:["read","clip","marker","tag"],box:["cube"],briefcase:["work","bag","baggage","folder"],calendar:["date"],camera:["photo"],cast:["chromecast","airplay"],circle:["off","zero","record"],clipboard:["copy"],clock:["time","watch","alarm"],"cloud-drizzle":["weather","shower"],"cloud-lightning":["weather","bolt"],"cloud-rain":["weather"],"cloud-snow":["weather","blizzard"],cloud:["weather"],codepen:["logo"],codesandbox:["logo"],code:["source","programming"],coffee:["drink","cup","mug","tea","cafe","hot","beverage"],columns:["layout"],command:["keyboard","cmd","terminal","prompt"],compass:["navigation","safari","travel","direction"],copy:["clone","duplicate"],"corner-down-left":["arrow","return"],"corner-down-right":["arrow"],"corner-left-down":["arrow"],"corner-left-up":["arrow"],"corner-right-down":["arrow"],"corner-right-up":["arrow"],"corner-up-left":["arrow"],"corner-up-right":["arrow"],cpu:["processor","technology"],"credit-card":["purchase","payment","cc"],crop:["photo","image"],crosshair:["aim","target"],database:["storage","memory"],delete:["remove"],disc:["album","cd","dvd","music"],"dollar-sign":["currency","money","payment"],droplet:["water"],edit:["pencil","change"],"edit-2":["pencil","change"],"edit-3":["pencil","change"],eye:["view","watch"],"eye-off":["view","watch","hide","hidden"],"external-link":["outbound"],facebook:["logo","social"],"fast-forward":["music"],figma:["logo","design","tool"],"file-minus":["delete","remove","erase"],"file-plus":["add","create","new"],"file-text":["data","txt","pdf"],film:["movie","video"],filter:["funnel","hopper"],flag:["report"],"folder-minus":["directory"],"folder-plus":["directory"],folder:["directory"],framer:["logo","design","tool"],frown:["emoji","face","bad","sad","emotion"],gift:["present","box","birthday","party"],"git-branch":["code","version control"],"git-commit":["code","version control"],"git-merge":["code","version control"],"git-pull-request":["code","version control"],github:["logo","version control"],gitlab:["logo","version control"],globe:["world","browser","language","translate"],"hard-drive":["computer","server","memory","data"],hash:["hashtag","number","pound"],headphones:["music","audio","sound"],heart:["like","love","emotion"],"help-circle":["question mark"],hexagon:["shape","node.js","logo"],home:["house","living"],image:["picture"],inbox:["email"],instagram:["logo","camera"],key:["password","login","authentication","secure"],layers:["stack"],layout:["window","webpage"],"life-bouy":["help","life ring","support"],link:["chain","url"],"link-2":["chain","url"],linkedin:["logo","social media"],list:["options"],lock:["security","password","secure"],"log-in":["sign in","arrow","enter"],"log-out":["sign out","arrow","exit"],mail:["email","message"],"map-pin":["location","navigation","travel","marker"],map:["location","navigation","travel"],maximize:["fullscreen"],"maximize-2":["fullscreen","arrows","expand"],meh:["emoji","face","neutral","emotion"],menu:["bars","navigation","hamburger"],"message-circle":["comment","chat"],"message-square":["comment","chat"],"mic-off":["record","sound","mute"],mic:["record","sound","listen"],minimize:["exit fullscreen","close"],"minimize-2":["exit fullscreen","arrows","close"],minus:["subtract"],monitor:["tv","screen","display"],moon:["dark","night"],"more-horizontal":["ellipsis"],"more-vertical":["ellipsis"],"mouse-pointer":["arrow","cursor"],move:["arrows"],music:["note"],navigation:["location","travel"],"navigation-2":["location","travel"],octagon:["stop"],package:["box","container"],paperclip:["attachment"],pause:["music","stop"],"pause-circle":["music","audio","stop"],"pen-tool":["vector","drawing"],percent:["discount"],"phone-call":["ring"],"phone-forwarded":["call"],"phone-incoming":["call"],"phone-missed":["call"],"phone-off":["call","mute"],"phone-outgoing":["call"],phone:["call"],play:["music","start"],"pie-chart":["statistics","diagram"],"play-circle":["music","start"],plus:["add","new"],"plus-circle":["add","new"],"plus-square":["add","new"],pocket:["logo","save"],power:["on","off"],printer:["fax","office","device"],radio:["signal"],"refresh-cw":["synchronise","arrows"],"refresh-ccw":["arrows"],repeat:["loop","arrows"],rewind:["music"],"rotate-ccw":["arrow"],"rotate-cw":["arrow"],rss:["feed","subscribe"],save:["floppy disk"],scissors:["cut"],search:["find","magnifier","magnifying glass"],send:["message","mail","email","paper airplane","paper aeroplane"],settings:["cog","edit","gear","preferences"],"share-2":["network","connections"],shield:["security","secure"],"shield-off":["security","insecure"],"shopping-bag":["ecommerce","cart","purchase","store"],"shopping-cart":["ecommerce","cart","purchase","store"],shuffle:["music"],"skip-back":["music"],"skip-forward":["music"],slack:["logo"],slash:["ban","no"],sliders:["settings","controls"],smartphone:["cellphone","device"],smile:["emoji","face","happy","good","emotion"],speaker:["audio","music"],star:["bookmark","favorite","like"],"stop-circle":["media","music"],sun:["brightness","weather","light"],sunrise:["weather","time","morning","day"],sunset:["weather","time","evening","night"],tablet:["device"],tag:["label"],target:["logo","bullseye"],terminal:["code","command line","prompt"],thermometer:["temperature","celsius","fahrenheit","weather"],"thumbs-down":["dislike","bad","emotion"],"thumbs-up":["like","good","emotion"],"toggle-left":["on","off","switch"],"toggle-right":["on","off","switch"],tool:["settings","spanner"],trash:["garbage","delete","remove","bin"],"trash-2":["garbage","delete","remove","bin"],triangle:["delta"],truck:["delivery","van","shipping","transport","lorry"],tv:["television","stream"],twitch:["logo"],twitter:["logo","social"],type:["text"],umbrella:["rain","weather"],unlock:["security"],"user-check":["followed","subscribed"],"user-minus":["delete","remove","unfollow","unsubscribe"],"user-plus":["new","add","create","follow","subscribe"],"user-x":["delete","remove","unfollow","unsubscribe","unavailable"],user:["person","account"],users:["group"],"video-off":["camera","movie","film"],video:["camera","movie","film"],voicemail:["phone"],volume:["music","sound","mute"],"volume-1":["music","sound"],"volume-2":["music","sound"],"volume-x":["music","sound","mute"],watch:["clock","time"],"wifi-off":["disabled"],wifi:["connection","signal","wireless"],wind:["weather","air"],"x-circle":["cancel","close","delete","remove","times","clear"],"x-octagon":["delete","stop","alert","warning","times","clear"],"x-square":["cancel","close","delete","remove","times","clear"],x:["cancel","close","delete","remove","times","clear"],youtube:["logo","video","play"],"zap-off":["flash","camera","lightning"],zap:["flash","camera","lightning"],"zoom-in":["magnifying glass"],"zoom-out":["magnifying glass"]}},function(e){e.exports={activity:'',airplay:'',"alert-circle":'',"alert-octagon":'',"alert-triangle":'',"align-center":'',"align-justify":'',"align-left":'',"align-right":'',anchor:'',aperture:'',archive:'',"arrow-down-circle":'',"arrow-down-left":'',"arrow-down-right":'',"arrow-down":'',"arrow-left-circle":'',"arrow-left":'',"arrow-right-circle":'',"arrow-right":'',"arrow-up-circle":'',"arrow-up-left":'',"arrow-up-right":'',"arrow-up":'',"at-sign":'',award:'',"bar-chart-2":'',"bar-chart":'',"battery-charging":'',battery:'',"bell-off":'',bell:'',bluetooth:'',bold:'',"book-open":'',book:'',bookmark:'',box:'',briefcase:'',calendar:'',"camera-off":'',camera:'',cast:'',"check-circle":'',"check-square":'',check:'',"chevron-down":'',"chevron-left":'',"chevron-right":'',"chevron-up":'',"chevrons-down":'',"chevrons-left":'',"chevrons-right":'',"chevrons-up":'',chrome:'',circle:'',clipboard:'',clock:'',"cloud-drizzle":'',"cloud-lightning":'',"cloud-off":'',"cloud-rain":'',"cloud-snow":'',cloud:'',code:'',codepen:'',codesandbox:'',coffee:'',columns:'',command:'',compass:'',copy:'',"corner-down-left":'',"corner-down-right":'',"corner-left-down":'',"corner-left-up":'',"corner-right-down":'',"corner-right-up":'',"corner-up-left":'',"corner-up-right":'',cpu:'',"credit-card":'',crop:'',crosshair:'',database:'',delete:'',disc:'',"divide-circle":'',"divide-square":'',divide:'',"dollar-sign":'',"download-cloud":'',download:'',dribbble:'',droplet:'',"edit-2":'',"edit-3":'',edit:'',"external-link":'',"eye-off":'',eye:'',facebook:'',"fast-forward":'',feather:'',figma:'',"file-minus":'',"file-plus":'',"file-text":'',file:'',film:'',filter:'',flag:'',"folder-minus":'',"folder-plus":'',folder:'',framer:'',frown:'',gift:'',"git-branch":'',"git-commit":'',"git-merge":'',"git-pull-request":'',github:'',gitlab:'',globe:'',grid:'',"hard-drive":'',hash:'',headphones:'',heart:'',"help-circle":'',hexagon:'',home:'',image:'',inbox:'',info:'',instagram:'',italic:'',key:'',layers:'',layout:'',"life-buoy":'',"link-2":'',link:'',linkedin:'',list:'',loader:'',lock:'',"log-in":'',"log-out":'',mail:'',"map-pin":'',map:'',"maximize-2":'',maximize:'',meh:'',menu:'',"message-circle":'',"message-square":'',"mic-off":'',mic:'',"minimize-2":'',minimize:'',"minus-circle":'',"minus-square":'',minus:'',monitor:'',moon:'',"more-horizontal":'',"more-vertical":'',"mouse-pointer":'',move:'',music:'',"navigation-2":'',navigation:'',octagon:'',package:'',paperclip:'',"pause-circle":'',pause:'',"pen-tool":'',percent:'',"phone-call":'',"phone-forwarded":'',"phone-incoming":'',"phone-missed":'',"phone-off":'',"phone-outgoing":'',phone:'',"pie-chart":'',"play-circle":'',play:'',"plus-circle":'',"plus-square":'',plus:'',pocket:'',power:'',printer:'',radio:'',"refresh-ccw":'',"refresh-cw":'',repeat:'',rewind:'',"rotate-ccw":'',"rotate-cw":'',rss:'',save:'',scissors:'',search:'',send:'',server:'',settings:'',"share-2":'',share:'',"shield-off":'',shield:'',"shopping-bag":'',"shopping-cart":'',shuffle:'',sidebar:'',"skip-back":'',"skip-forward":'',slack:'',slash:'',sliders:'',smartphone:'',smile:'',speaker:'',square:'',star:'',"stop-circle":'',sun:'',sunrise:'',sunset:'',tablet:'',tag:'',target:'',terminal:'',thermometer:'',"thumbs-down":'',"thumbs-up":'',"toggle-left":'',"toggle-right":'',tool:'',"trash-2":'',trash:'',trello:'',"trending-down":'',"trending-up":'',triangle:'',truck:'',tv:'',twitch:'',twitter:'',type:'',umbrella:'',underline:'',unlock:'',"upload-cloud":'',upload:'',"user-check":'',"user-minus":'',"user-plus":'',"user-x":'',user:'',users:'',"video-off":'',video:'',voicemail:'',"volume-1":'',"volume-2":'',"volume-x":'',volume:'',watch:'',"wifi-off":'',wifi:'',wind:'',"x-circle":'',"x-octagon":'',"x-square":'',x:'',youtube:'',"zap-off":'',zap:'',"zoom-in":'',"zoom-out":''}},function(e){e.exports={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"}},function(e,n,i){"use strict";Object.defineProperty(n,"__esModule",{value:!0});var t=Object.assign||function(e){for(var n=1;n2&&void 0!==arguments[2]?arguments[2]:[];!function(e,n){if(!(e instanceof n))throw new TypeError("Cannot call a class as a function")}(this,e),this.name=n,this.contents=i,this.tags=l,this.attrs=t({},o.default,{class:"feather feather-"+n})}return l(e,[{key:"toSvg",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return""+this.contents+""}},{key:"toString",value:function(){return this.contents}}]),e}();n.default=c},function(e,n,i){"use strict";var t=o(i(12)),l=o(i(39)),r=o(i(38));function o(e){return e&&e.__esModule?e:{default:e}}e.exports={icons:t.default,toSvg:l.default,replace:r.default}},function(e,n,i){e.exports=i(0)},function(e,n,i){var t=i(2)("iterator"),l=!1;try{var r=0,o={next:function(){return{done:!!r++}},return:function(){l=!0}};o[t]=function(){return this},Array.from(o,function(){throw 2})}catch(e){}e.exports=function(e,n){if(!n&&!l)return!1;var i=!1;try{var r={};r[t]=function(){return{next:function(){return{done:i=!0}}}},e(r)}catch(e){}return i}},function(e,n,i){var t=i(30),l=i(2)("toStringTag"),r="Arguments"==t(function(){return arguments}());e.exports=function(e){var n,i,o;return void 0===e?"Undefined":null===e?"Null":"string"==typeof(i=function(e,n){try{return e[n]}catch(e){}}(n=Object(e),l))?i:r?t(n):"Object"==(o=t(n))&&"function"==typeof n.callee?"Arguments":o}},function(e,n,i){var t=i(47),l=i(9),r=i(2)("iterator");e.exports=function(e){if(void 0!=e)return e[r]||e["@@iterator"]||l[t(e)]}},function(e,n,i){"use strict";var t=i(18),l=i(7),r=i(10);e.exports=function(e,n,i){var o=t(n);o in e?l.f(e,o,r(0,i)):e[o]=i}},function(e,n,i){var t=i(2),l=i(9),r=t("iterator"),o=Array.prototype;e.exports=function(e){return void 0!==e&&(l.Array===e||o[r]===e)}},function(e,n,i){var t=i(3);e.exports=function(e,n,i,l){try{return l?n(t(i)[0],i[1]):n(i)}catch(n){var r=e.return;throw void 0!==r&&t(r.call(e)),n}}},function(e,n){e.exports=function(e){if("function"!=typeof e)throw TypeError(String(e)+" is not a function");return e}},function(e,n,i){var t=i(52);e.exports=function(e,n,i){if(t(e),void 0===n)return e;switch(i){case 0:return function(){return e.call(n)};case 1:return function(i){return e.call(n,i)};case 2:return function(i,t){return e.call(n,i,t)};case 3:return function(i,t,l){return e.call(n,i,t,l)}}return function(){return e.apply(n,arguments)}}},function(e,n,i){"use strict";var t=i(53),l=i(24),r=i(51),o=i(50),a=i(27),c=i(49),p=i(48);e.exports=function(e){var n,i,y,h,x=l(e),s="function"==typeof this?this:Array,u=arguments.length,d=u>1?arguments[1]:void 0,f=void 0!==d,g=0,v=p(x);if(f&&(d=t(d,u>2?arguments[2]:void 0,2)),void 0==v||s==Array&&o(v))for(i=new s(n=a(x.length));n>g;g++)c(i,g,f?d(x[g],g):x[g]);else for(h=v.call(x),i=new s;!(y=h.next()).done;g++)c(i,g,f?r(h,d,[y.value,g],!0):y.value);return i.length=g,i}},function(e,n,i){var t=i(32),l=i(54);t({target:"Array",stat:!0,forced:!i(46)(function(e){Array.from(e)})},{from:l})},function(e,n,i){var t=i(6),l=i(3);e.exports=function(e,n){if(l(e),!t(n)&&null!==n)throw TypeError("Can't set "+String(n)+" as a prototype")}},function(e,n,i){var t=i(56);e.exports=Object.setPrototypeOf||("__proto__"in{}?function(){var e,n=!1,i={};try{(e=Object.getOwnPropertyDescriptor(Object.prototype,"__proto__").set).call(i,[]),n=i instanceof Array}catch(e){}return function(i,l){return t(i,l),n?e.call(i,l):i.__proto__=l,i}}():void 0)},function(e,n,i){var t=i(0).document;e.exports=t&&t.documentElement},function(e,n,i){var t=i(28),l=i(13);e.exports=Object.keys||function(e){return t(e,l)}},function(e,n,i){var t=i(8),l=i(7),r=i(3),o=i(59);e.exports=t?Object.defineProperties:function(e,n){r(e);for(var i,t=o(n),a=t.length,c=0;a>c;)l.f(e,i=t[c++],n[i]);return e}},function(e,n,i){var t=i(3),l=i(60),r=i(13),o=i(15),a=i(58),c=i(34),p=i(16)("IE_PROTO"),y=function(){},h=function(){var e,n=c("iframe"),i=r.length;for(n.style.display="none",a.appendChild(n),n.src=String("javascript:"),(e=n.contentWindow.document).open(),e.write(" +``` + +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) diff --git a/django/messages_extends/__init__.py b/django/messages_extends/__init__.py new file mode 100644 index 0000000..227a349 --- /dev/null +++ b/django/messages_extends/__init__.py @@ -0,0 +1,68 @@ +from django.contrib.messages.api import MessageFailure +from messages_extends.constants import * +from django.contrib import messages + +messages.DEFAULT_TAGS.update(DEFAULT_TAGS) + +def add_message(request, level, message, extra_tags='', fail_silently=False, *args, **kwargs): + """ + Attempts to add a message to the request using the 'messages' app. + """ + if hasattr(request, '_messages'): + return request._messages.add(level, message, extra_tags, *args, **kwargs) + if not fail_silently: + raise MessageFailure('You cannot add messages without installing ' + 'django.contrib.messages.middleware.MessageMiddleware') + +messages.add_message = add_message + + +def persistant_debug(request, message, extra_tags='', fail_silently=False, *args, **kwargs): + """ + Adds a persistant message with the ``DEBUG`` level. + """ + add_message(request, DEBUG_PERSISTENT, message, extra_tags=extra_tags, + fail_silently=fail_silently, *args, **kwargs) + +messages.persistant_debug = persistant_debug + + +def persistant_info(request, message, extra_tags='', fail_silently=False, *args, **kwargs): + """ + Adds a persistant message with the ``INFO`` level. + """ + add_message(request, INFO_PERSISTENT, message, extra_tags=extra_tags, + fail_silently=fail_silently, *args, **kwargs) + +messages.persistant_info = persistant_info + + +def persistant_success(request, message, extra_tags='', fail_silently=False, *args, **kwargs): + """ + Adds a persistant message with the ``SUCCESS`` level. + """ + add_message(request, SUCCESS_PERSISTENT, message, extra_tags=extra_tags, + fail_silently=fail_silently, *args, **kwargs) + +messages.persistant_success = persistant_success + + +def persistant_warning(request, message, extra_tags='', fail_silently=False, *args, **kwargs): + """ + Adds a persistant message with the ``WARNING`` level. + """ + add_message(request, WARNING_PERSISTENT, message, extra_tags=extra_tags, + fail_silently=fail_silently, *args, **kwargs) + +messages.persistant_warning = persistant_warning + + +def persistant_error(request, message, extra_tags='', fail_silently=False, *args, **kwargs): + """ + Adds a persistant message with the ``ERROR`` level. + """ + add_message(request, ERROR_PERSISTENT, message, extra_tags=extra_tags, + fail_silently=fail_silently, *args, **kwargs) + +messages.persistant_error = persistant_error + diff --git a/django/messages_extends/admin.py b/django/messages_extends/admin.py new file mode 100644 index 0000000..c27473b --- /dev/null +++ b/django/messages_extends/admin.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +"""admin.py: messages extends""" + +from messages_extends.models import Message +from django.contrib import admin + +class MessageAdmin(admin.ModelAdmin): + list_display = ['level', 'user', 'message', 'created', 'read'] + +admin.site.register(Message, MessageAdmin) diff --git a/django/messages_extends/constants.py b/django/messages_extends/constants.py new file mode 100644 index 0000000..2c1dfd0 --- /dev/null +++ b/django/messages_extends/constants.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +"""constants.py: messages extends""" + + +DEBUG = 10 +INFO = 20 +SUCCESS = 25 +WARNING = 30 +ERROR = 40 + +DEBUG_PERSISTENT = 11 +INFO_PERSISTENT = 21 +SUCCESS_PERSISTENT = 26 +WARNING_PERSISTENT = 31 +ERROR_PERSISTENT = 41 + +DEBUG_STICKY = 12 +INFO_STICKY = 22 +SUCCESS_STICKY = 27 +WARNING_STICKY = 32 +ERROR_STICKY = 42 + +DEFAULT_TAGS = { + DEBUG_PERSISTENT: 'debug persistent', + INFO_PERSISTENT: 'info persistent', + SUCCESS_PERSISTENT: 'success persistent', + WARNING_PERSISTENT: 'warning persistent', + ERROR_PERSISTENT: 'error persistent', + + DEBUG_STICKY: 'debug sticky', + INFO_STICKY: 'info sticky', + SUCCESS_STICKY: 'success sticky', + WARNING_STICKY: 'warning sticky', + ERROR_STICKY: 'error sticky', + +} + +PERSISTENT_MESSAGE_LEVELS = ( + DEBUG_PERSISTENT, INFO_PERSISTENT, SUCCESS_PERSISTENT, WARNING_PERSISTENT, ERROR_PERSISTENT +) +STICKY_MESSAGE_LEVELS = ( + DEBUG_STICKY, INFO_STICKY, SUCCESS_STICKY, WARNING_STICKY, ERROR_STICKY +) diff --git a/django/messages_extends/exceptions.py b/django/messages_extends/exceptions.py new file mode 100644 index 0000000..9c67684 --- /dev/null +++ b/django/messages_extends/exceptions.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +"""admin.py: messages extends""" + +__author__ = 'ali' + +class LevelOfMessageException(Exception): + + def __init__(self, *args, **kwargs): + super(LevelOfMessageException, self).__init__(*args, **kwargs) + + def __str__(self): + return "The level of the message, can't be proccess by this storage" diff --git a/django/messages_extends/migrations/0001_initial.py b/django/messages_extends/migrations/0001_initial.py new file mode 100644 index 0000000..a2c5d09 --- /dev/null +++ b/django/messages_extends/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('message', models.TextField()), + ('level', models.IntegerField(choices=[(11, 'PERSISTENT DEBUG'), (21, 'PERSISTENT INFO'), (26, 'PERSISTENT SUCCESS'), (31, 'PERSISTENT WARNING'), (41, 'PERSISTENT ERROR')])), + ('extra_tags', models.CharField(max_length=128)), + ('created', models.DateTimeField(auto_now_add=True)), + ('modified', models.DateTimeField(auto_now=True)), + ('read', models.BooleanField(default=False)), + ('expires', models.DateTimeField(null=True, blank=True)), + ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), + ], + ), + ] diff --git a/django/messages_extends/migrations/0002_alter_message_id.py b/django/messages_extends/migrations/0002_alter_message_id.py new file mode 100644 index 0000000..c0acf1e --- /dev/null +++ b/django/messages_extends/migrations/0002_alter_message_id.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2024-07-31 06:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('messages_extends', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='message', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/django/messages_extends/migrations/__init__.py b/django/messages_extends/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django/messages_extends/models.py b/django/messages_extends/models.py new file mode 100644 index 0000000..2b3026a --- /dev/null +++ b/django/messages_extends/models.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""models.py: messages extends""" + +import messages_extends +from django.db import models +from django.utils.encoding import force_str +from django.contrib.messages import utils +from django.conf import settings + +LEVEL_TAGS = utils.get_level_tags() + +class Message(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, + on_delete=models.CASCADE) + message = models.TextField() + LEVEL_CHOICES = ( + (messages_extends.DEBUG_PERSISTENT, 'PERSISTENT DEBUG'), + (messages_extends.INFO_PERSISTENT, 'PERSISTENT INFO'), + (messages_extends.SUCCESS_PERSISTENT, 'PERSISTENT SUCCESS'), + (messages_extends.WARNING_PERSISTENT, 'PERSISTENT WARNING'), + (messages_extends.ERROR_PERSISTENT, 'PERSISTENT ERROR'), + ) + level = models.IntegerField(choices=LEVEL_CHOICES) + extra_tags = models.CharField(max_length=128) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + read = models.BooleanField(default=False) + expires = models.DateTimeField(null=True, blank=True) + + def __eq__(self, other): + return isinstance(other, Message) and self.level == other.level and\ + self.message == other.message + + __hash__ = models.Model.__hash__ + + def __str__(self): + return force_str(self.message) + + def _prepare_message(self): + """ + Prepares the message for saving by forcing the ``message`` + and ``extra_tags`` and ``subject`` to unicode in case they are lazy translations. + + Known "safe" types (None, int, etc.) are not converted (see Django's + ``force_str`` implementation for details). + """ + self.message = force_str(self.message, strings_only=True) + self.extra_tags = force_str(self.extra_tags, strings_only=True) + + def save(self, *args, **kwargs): + self._prepare_message() + super(Message, self).save(*args, **kwargs) + + def _get_tags(self): + label_tag = force_str(LEVEL_TAGS.get(self.level, ''), + strings_only=True) + extra_tags = force_str(self.extra_tags, strings_only=True) + + if self.read: + read_tag = "read" + else: + read_tag = "unread" + + if extra_tags and label_tag: + return u' '.join([extra_tags, label_tag, read_tag]) + elif extra_tags: + return u' '.join([extra_tags, read_tag]) + elif label_tag: + return u' '.join([label_tag, read_tag]) + return read_tag + + tags = property(_get_tags) diff --git a/django/messages_extends/static/close-alerts.js b/django/messages_extends/static/close-alerts.js new file mode 100644 index 0000000..bc33960 --- /dev/null +++ b/django/messages_extends/static/close-alerts.js @@ -0,0 +1,9 @@ +$(function() { + $("a.close[close-href]").click(function (e) { + e.preventDefault(); + $.post($(this).attr("close-href"), "", function () { + }); + } + ); +}); + diff --git a/django/messages_extends/storages.py b/django/messages_extends/storages.py new file mode 100644 index 0000000..5b91d36 --- /dev/null +++ b/django/messages_extends/storages.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +"""storages.py: messages extends""" + +from django.utils.module_loading import import_string as get_storage +from django.contrib.messages.storage.base import BaseStorage, Message +from django.conf import settings +from messages_extends.models import Message as PersistentMessage +from messages_extends.constants import PERSISTENT_MESSAGE_LEVELS, STICKY_MESSAGE_LEVELS +from django.contrib.auth.models import AnonymousUser +from django.db.models import Q +try: + from django.utils import timezone +except ImportError: + from datetime import datetime as timezone + +__author__ = 'ali' + +class FallbackStorage(BaseStorage): + """ + Tries to store all messages in the first backend, storing any unstored + messages in each subsequent backend backend, by default use + MESSAGES_STORAGES = ('messages_extends.storages.StickyStorage', + 'messages_extends.storages.PersistentStorage', + 'django.contrib.messages.storage.session.CookieStorage', + 'django.contrib.messages.storage.session.SessionStorage')) + if you want change the backends, put your custom storages: + MESSAGES_STORAGES = ('foo.your_storage', 'cookie_storage') + in your settings + """ + + storages_names = getattr(settings, 'MESSAGES_STORAGES', + ('messages_extends.storages.StickyStorage', + 'messages_extends.storages.PersistentStorage', + 'django.contrib.messages.storage.cookie.CookieStorage', + 'django.contrib.messages.storage.session.SessionStorage')) + + def __init__(self, *args, **kwargs): + super(FallbackStorage, self).__init__(*args, **kwargs) + + # get instances of classes of storages_names + self.storages = [get_storage(storage)(*args, **kwargs) + for storage in self.storages_names] + + self._used_storages = set() + + def _get(self, *args, **kwargs): + """ + Gets a single list of messages from all storage backends. + """ + all_messages = [] + for storage in self.storages: + messages, all_retrieved = storage._get() + # If the backend hasn't been used, no more retrieval is necessary. + if messages is None: + break + if messages: + self._used_storages.add(storage) + all_messages.extend(messages) + # If this storage class contained all the messages, no further + # retrieval is necessary + if all_retrieved: + break + return all_messages, all_retrieved + + def _store(self, messages, response, *args, **kwargs): + """ + Stores the messages, returning any unstored messages after trying all + backends. + + For each storage backend, any messages not stored are passed on to the + next backend. + """ + for storage in self.storages: + if messages: + messages = storage._store(messages, response, + remove_oldest=False) + # Even if there are no more messages, continue iterating to ensure + # storages which contained messages are flushed. + elif storage in self._used_storages: + storage._store([], response) + self._used_storages.remove(storage) + return messages + + + def add(self, level, message, extra_tags='', *args, **kwargs): + """ + Queues a message to be stored. + + The message is only queued if it contained something and its level is + not less than the recording level (``self.level``). + """ + if not message: + return + # Check that the message level is not less than the recording level. + level = int(level) + if level < self.level: + return + # Add the message + self.added_new = True + message = Message(level, message, extra_tags=extra_tags) + for storage in self.storages: + if hasattr(storage, 'process_message'): + message = storage.process_message(message, *args, **kwargs) + if not message: + return + self._queued_messages.append(message) + + def _prepare_messages(self, messages): + """ + Prepares a list of messages for storage. + """ + for message in messages: + if hasattr(message, '_prepare'): + message._prepare() + + +class PersistentStorage(BaseStorage): + """ + Save persistent messages in data base + """ + + def __init__(self, request, *args, **kwargs): + self._sticky_messages = [] + super(PersistentStorage, self).__init__(request, *args, **kwargs) + + def _message_queryset(self, include_read=False): + """ + Return a queryset of messages for the request user + """ + expire = timezone.now() + + + qs = PersistentMessage.objects.\ + filter(user=self.get_user()).\ + filter(Q(expires=None) | Q(expires__gt=expire)) + if not include_read: + qs = qs.exclude(read=True) + return qs + + + def _get(self, *args, **kwargs): + """ + Retrieves a list of stored messages. Returns a tuple of the messages + and a flag indicating whether or not all the messages originally + intended to be stored in this storage were, in fact, stored and + retrieved; e.g., ``(messages, all_retrieved)``. + """ + is_authenticated = self.get_user().is_authenticated + if callable(is_authenticated): + is_authenticated = is_authenticated() + if is_authenticated is not True: + return [], False + return self._message_queryset(), False + + def _store(self, messages, response, *args, **kwargs): + #There are alredy saved. + return [message for message in messages if not message.level in PERSISTENT_MESSAGE_LEVELS] + + def process_message(self, message, *args, **kwargs): + """ + If its level is into persist levels, convert the message to models and save it + """ + if not message.level in PERSISTENT_MESSAGE_LEVELS: + return message + + user = kwargs.get("user") or self.get_user() + + try: + anonymous = user.is_anonymous() + except TypeError: + anonymous = user.is_anonymous + if anonymous: + raise NotImplementedError('Persistent message levels cannot be used for anonymous users.') + message_persistent = PersistentMessage() + message_persistent.level = message.level + message_persistent.message = message.message + message_persistent.extra_tags = message.extra_tags + message_persistent.user = user + + if "expires" in kwargs: + message_persistent.expires = kwargs["expires"] + message_persistent.save() + return None + + def add(self, level, message, extra_tags='', *args, **kwargs): + """ + Queues a message to be stored. + + The message is only queued if it contained something and its level is + not less than the recording level (``self.level``). + """ + if not message: + return + # Check that the message level is not less than the recording level. + level = int(level) + if level < self.level: + return + # Add the message. + self.added_new = True + message = Message(level, message, extra_tags=extra_tags) + message = self.process_message(message, *args, **kwargs) + if message: + self._queued_messages.append(message) + + def get_user(self): + if hasattr(self.request, 'user'): + return self.request.user + else: + return AnonymousUser() + + +class StickyStorage(BaseStorage): + """ + Keep messages that are sticky in memory + """ + + def __init__(self, request, *args, **kwargs): + super(StickyStorage, self).__init__(request, *args, **kwargs) + + def _get(self, *args, **kwargs): + """ + Retrieves a list of messages from the memory. + """ + return [], False + + def _store(self, messages, response, *args, **kwargs): + """ + Delete all messages that are sticky and return the other messages + This storage never save objects + """ + return [message for message in messages if not message.level in STICKY_MESSAGE_LEVELS] diff --git a/django/messages_extends/templates/messages_extends/includes/alerts_bootstrap.html b/django/messages_extends/templates/messages_extends/includes/alerts_bootstrap.html new file mode 100644 index 0000000..f344383 --- /dev/null +++ b/django/messages_extends/templates/messages_extends/includes/alerts_bootstrap.html @@ -0,0 +1,7 @@ +{% for message in messages %} +
+ {# close-href is used because href is used by bootstrap to closing other divs #} + × + {{ message|safe }} +
+{% endfor %} diff --git a/django/messages_extends/urls.py b/django/messages_extends/urls.py new file mode 100644 index 0000000..b4618bf --- /dev/null +++ b/django/messages_extends/urls.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +"""urls.py: messages extends""" + +from django.urls import re_path, path +from messages_extends.views import message_mark_all_read, message_mark_read + +app_name = 'messages_extends' + +urlpatterns = [ + re_path(r'^mark_read/(?P\d+)/$', message_mark_read, name='message_mark_read'), + path('mark_read/all/', message_mark_all_read, name='message_mark_all_read'), +] diff --git a/django/messages_extends/views.py b/django/messages_extends/views.py new file mode 100644 index 0000000..a44f8e4 --- /dev/null +++ b/django/messages_extends/views.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""views.py: messages extends""" + +from messages_extends.models import Message +from django.http import HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404 +from django.core.exceptions import PermissionDenied + + +def callable_or_bool(fn): + if callable(fn): + return fn() + return fn + + +def message_mark_read(request, message_id): + if not callable_or_bool(request.user.is_authenticated): + raise PermissionDenied + message = get_object_or_404(Message, user=request.user, pk=message_id) + message.read = True + message.save() + if not request.headers.get('x-requested-with') == 'XMLHttpRequest': + return HttpResponseRedirect(request.META.get('HTTP_REFERER') or '/') + else: + return HttpResponse('') + +def message_mark_all_read(request): + if not callable_or_bool(request.user.is_authenticated): + raise PermissionDenied + Message.objects.filter(user=request.user).update(read=True) + if not request.headers.get('x-requested-with') == 'XMLHttpRequest': + return HttpResponseRedirect(request.META.get('HTTP_REFERER') or '/') + else: + return HttpResponse('') diff --git a/django/notifications/__init__.py b/django/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django/notifications/utils.py b/django/notifications/utils.py new file mode 100644 index 0000000..066b34d --- /dev/null +++ b/django/notifications/utils.py @@ -0,0 +1,39 @@ +from django.contrib.auth.models import User +from messages_extends.models import Message +from messages_extends import constants +""" +DEBUG = 10 +INFO = 20 +SUCCESS = 25 +WARNING = 30 +ERROR = 40 + +DEBUG_PERSISTENT = 11 +INFO_PERSISTENT = 21 +SUCCESS_PERSISTENT = 26 +WARNING_PERSISTENT = 31 +ERROR_PERSISTENT = 41 + +DEBUG_STICKY = 12 +INFO_STICKY = 22 +SUCCESS_STICKY = 27 +WARNING_STICKY = 32 +ERROR_STICKY = 42 + +""" +def notify_user(username, message, level=constants.SUCCESS_PERSISTENT): + u = User.objects.get(username=username) + m = Message(user=u, message=message, level=level) + m.save() + +def notify_success_persistent(username, message): + notify_user(username, message, level=constants.SUCCESS_PERSISTENT) + +def notify_warning_persistent(username, message): + notify_user(username, message, level=constants.WARNING_PERSISTENT) + +def notify_error_persistent(username, message): + notify_user(username, message, level=constants.ERROR_PERSISTENT) + +def notify_error(username, message): + notify_user(username, message, level=constants.ERROR) diff --git a/django/requirements.txt b/django/requirements.txt index 808b053..d6e115f 100644 --- a/django/requirements.txt +++ b/django/requirements.txt @@ -1,12 +1,14 @@ gunicorn -asgiref==3.4.1 -certifi==2023.07.22 -cffi==1.15.1 -charset-normalizer==2.0.12 +asgiref +certifi +cffi +charset-normalizer coreapi==2.3.3 coreschema==0.0.4 cryptography==42.0.4 +pycryptodome Django==3.2.25 +#Django==4.2.26 django-appconf==1.0.5 django-bootstrap-form==3.4 django-classy-tags==2.0.0 @@ -23,10 +25,10 @@ django-phonenumber-field==6.0.0 django-sekizai==2.0.0 django-user-sessions==2.0.0 djangorestframework==3.14.0 -idna==3.4 +idna==3.7 importlib-metadata==4.8.3 itypes==1.2.0 -Jinja2==3.1.3 +Jinja2==3.1.4 Markdown==3.3.7 MarkupSafe==2.0.1 mysqlclient>=2.0,<3 @@ -39,9 +41,9 @@ six==1.16.0 sqlparse==0.5.0 typing_extensions==4.1.1 uritemplate==4.1.1 -urllib3==1.26.18 +urllib3>=2.6.0 zipp==3.6.0 -bip329==0.0.3 +bip329==1.0.0 pymempool==0.0.5 django-datatables-view==1.20.0 django-money==3.3 diff --git a/django/shared/__init__.py b/django/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django/shared/encryption.py b/django/shared/encryption.py new file mode 100644 index 0000000..9d418c7 --- /dev/null +++ b/django/shared/encryption.py @@ -0,0 +1,31 @@ +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.backends import default_backend +from cryptography.fernet import Fernet +import base64 +import json +from django.conf import settings + +def get_fernet_key(secret_key, salt): + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + backend=default_backend() + ) + key = base64.urlsafe_b64encode(kdf.derive(secret_key.encode())) + return Fernet(key) + +# Create a Fernet cipher suite using the derived key +cipher_suite = get_fernet_key(settings.SECRET_KEY, settings.CRYPTOGRAPHY_SALT) + +def encrypt_data(data): + json_data = json.dumps(data).encode('utf-8') + encrypted_data = cipher_suite.encrypt(json_data) + return encrypted_data.decode('utf-8') + +def decrypt_data(encrypted_data): + encrypted_data_bytes = encrypted_data.encode('utf-8') + decrypted_data = cipher_suite.decrypt(encrypted_data_bytes) + return json.loads(decrypted_data.decode('utf-8')) diff --git a/django/templates/_base.html b/django/templates/_base.html index 19137f4..2169017 100644 --- a/django/templates/_base.html +++ b/django/templates/_base.html @@ -41,6 +41,26 @@ {% render_block "css" %} + + @@ -70,7 +90,6 @@ - diff --git a/django/templates/_intro.html b/django/templates/_intro.html index 8332de6..e536e8e 100644 --- a/django/templates/_intro.html +++ b/django/templates/_intro.html @@ -95,11 +95,6 @@ - - diff --git a/django/templates/_labelbase_header_info_menu.html b/django/templates/_labelbase_header_info_menu.html index 7f65bbe..77b9b88 100644 --- a/django/templates/_labelbase_header_info_menu.html +++ b/django/templates/_labelbase_header_info_menu.html @@ -8,37 +8,42 @@ {% endif %}
+
- - - {% comment %} - - Export - {% endcomment %} +--> + + + + + +
- + + + +
diff --git a/django/templates/_modal_add_label.html b/django/templates/_modal_add_label.html index 2c3ce4b..d6354b6 100644 --- a/django/templates/_modal_add_label.html +++ b/django/templates/_modal_add_label.html @@ -1,5 +1,4 @@ {% load bootstrap %} -
+ + + + + + diff --git a/django/templates/_modal_connect_api_key.html b/django/templates/_modal_connect_api_key.html new file mode 100644 index 0000000..f0366a9 --- /dev/null +++ b/django/templates/_modal_connect_api_key.html @@ -0,0 +1,35 @@ + diff --git a/django/templates/_modal_delete_labelbase.html b/django/templates/_modal_delete_labelbase.html new file mode 100644 index 0000000..53d2976 --- /dev/null +++ b/django/templates/_modal_delete_labelbase.html @@ -0,0 +1,26 @@ + diff --git a/django/templates/_modal_edit_labelbase.html b/django/templates/_modal_edit_labelbase.html new file mode 100644 index 0000000..1b12ce1 --- /dev/null +++ b/django/templates/_modal_edit_labelbase.html @@ -0,0 +1,24 @@ +{% load i18n %} +{% load labelbase_tags %} +{% load bootstrap %} + diff --git a/django/templates/_modal_importWizzardModal.html b/django/templates/_modal_importWizzardModal.html index 3217cc1..2769f12 100644 --- a/django/templates/_modal_importWizzardModal.html +++ b/django/templates/_modal_importWizzardModal.html @@ -18,9 +18,19 @@
+ {% addtoblock "js" %} + $(document).ready(function () { - var form = $('#importLabelbaseModal form'); - form.on('submit', function () { - // Disable the OK button on form submission - $('#importLabelbaseModal button[type="submit"]').prop('disabled', true); + var form = $('#importLabelbaseModal form'); + var importTypeField = $('#id_import_type'); + var passphraseFieldGroup = $('#id_passphrase').closest('.form-group'); + + function togglePassphraseField() { + if (importTypeField.val() === 'samourai') { + passphraseFieldGroup.show(); + $('#id_passphrase').prop('disabled', false); + $('#samourai-warning').show(); + } else { + passphraseFieldGroup.hide(); + $('#id_passphrase').prop('disabled', true); + $('#id_passphrase').val('') + $('#samourai-warning').hide(); + } + } + + importTypeField.on('change', togglePassphraseField); + + // Initial call to set the correct state on page load + togglePassphraseField(); + + form.on('submit', function () { + // Disable the OK button on form submission + $('#importLabelbaseModal button[type="submit"]').prop('disabled', true); + }); }); -}); + {% endaddtoblock %} diff --git a/django/templates/attachments/add.html b/django/templates/attachments/add.html index 79667f3..13f507b 100644 --- a/django/templates/attachments/add.html +++ b/django/templates/attachments/add.html @@ -1,5 +1,4 @@ {% extends "attachments/base.html" %} - {% block content %} {% include "attachments/add_form.html" %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/django/templates/attachments/add_form.html b/django/templates/attachments/add_form.html index 96a43a9..9414688 100644 --- a/django/templates/attachments/add_form.html +++ b/django/templates/attachments/add_form.html @@ -8,7 +8,5 @@ {{ form|bootstrap }}
- - {% endif %} diff --git a/django/templates/cloud.html b/django/templates/cloud.html new file mode 100644 index 0000000..347533c --- /dev/null +++ b/django/templates/cloud.html @@ -0,0 +1,51 @@ +{% extends "_base.html" %} +{% load i18n %} + +{% block title %}Interoperability{% endblock %} +{% block nav_home %}active{% endblock %} + +{% block content %} +
+

Lbelbase

+

All your labels in one place.

+
+ + + +
+ + +
+
+
+ ... +
+

Cloud

+ +

"Use your labels here, use your labels there – Dave doesn't care."

+

+ 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. +

+ + +

+

+ Get started +

+
+
+
+
+ +
+ +{% endblock %} diff --git a/django/templates/currency_sync.html b/django/templates/currency_sync.html new file mode 100644 index 0000000..4c31915 --- /dev/null +++ b/django/templates/currency_sync.html @@ -0,0 +1,212 @@ +{% extends "_base.html" %} +{% load i18n %} +{% load labelbase_tags %} +{% load sekizai_tags %} + +{% block content %} + +{% if labelbase %} +
+
+

Currency Data Sync - {{ labelbase.name }}

+ {% if labelbase.fingerprint %}{{ labelbase.fingerprint }}{% endif %} + {% if labelbase.about %} +

{{ labelbase.about }}

+ {% endif %} +
+
+ +
+

+ Sync currency data between label text (legacy format like "CHF 615.00") and the structured FMV field. +

+ + {% if text_only or fmv_only or conflicts %} +
+ {{ text_only|length|add:fmv_only|length|add:conflicts|length }} labels need attention! +
+ {% else %} +
+ All good! All {{ synced|length }} labels have matching currency data. +
+ {% endif %} + + + {% if text_only %} +
+
+
+ {{ text_only|length }} Labels with currency in text but no FMV field + +
+

These labels have currency values in the label text that can be synced to the FMV field.

+
+
+
+
    + {% for label in text_only %} +
  • +
    +
    + {{ label.get_type_display }}: {{ label.ref }}
    + Label: {{ label.label }}
    + FMV: (empty) +
    +
    +
    + {% csrf_token %} + + + +
    + Edit +
    +
    +
  • + {% endfor %} +
+
+ {% csrf_token %} + + +
+
+
+
+ {% endif %} + + + {% if fmv_only %} +
+
+
+ {{ fmv_only|length }} Labels with FMV but no currency in text + +
+

These labels have FMV data that can be added to the label text.

+
+
+
+
    + {% for label in fmv_only %} +
  • +
    +
    + {{ label.get_type_display }}: {{ label.ref }}
    + Label: {{ label.label|default:"(empty)" }}
    + FMV: {{ label.fmv }} +
    +
    +
    + {% csrf_token %} + + + +
    + Edit +
    +
    +
  • + {% endfor %} +
+
+ {% csrf_token %} + + +
+
+
+
+ {% endif %} + + + {% if conflicts %} +
+
+
+ {{ conflicts|length }} Labels with conflicting currency data + +
+

These labels have mismatched currency values between label text and FMV field.

+
+
+
+
    + {% for label in conflicts %} +
  • +
    +
    + {{ label.get_type_display }}: {{ label.ref }}
    + Label: {{ label.label }}
    + FMV: {{ label.fmv }} +
    +
    +
    + {% csrf_token %} + + + +
    +
    + {% csrf_token %} + + + +
    + Edit Manually +
    +
    +
  • + {% endfor %} +
+
+
+
+ {% endif %} + + + {% if synced %} +
+
+
+ ✓ {{ synced|length }} Labels with matching currency data + +
+

These labels have currency data properly synced between label text and FMV field.

+
+
+
+
    + {% for label in synced %} +
  • +
    +
    + {{ label.get_type_display }}: {{ label.ref }}
    + Label: {{ label.label }}
    + FMV: {{ label.fmv }} +
    +
    + Edit +
    +
    +
  • + {% endfor %} +
+
+
+
+ {% endif %} + +
+ +{% else %} +

Labelbase not found.

+{% endif %} + + + +{% endblock %} diff --git a/django/templates/donate.html b/django/templates/donate.html index 3056ac5..dc6a32d 100644 --- a/django/templates/donate.html +++ b/django/templates/donate.html @@ -7,22 +7,106 @@ {% block content %} -
-

Support Labelbase: Make a difference with your donation

-
-

Every contribution helps us build a better Bitcoin labeling experience.

-

-Choose the amount you'd like to donate to Labelbase. -

-Your generosity ensures that our project continues to thrive and evolve, directly supporting our mission to make Bitcoin transactions more organized and transparent. -

-Thank you for your support! + +

+

Support Labelbase: Keep it Going

+ +

+ Every contribution helps us continue building a better Bitcoin labeling experience. +

+

+ Choose the amount you'd like to donate to Labelbase. +

+

+ 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. +

+ + + + +
+ + + + + + +
+ + + +
+ +
+ + +


+ Thank you for keeping Labelbase going.

- - - - +
+ + Seedor powers our BTCPay Server instance, providing secure and private donations. + +
diff --git a/django/templates/electrum_server_info_update.html b/django/templates/electrum_server_info_update.html index 1388885..f9ddf90 100644 --- a/django/templates/electrum_server_info_update.html +++ b/django/templates/electrum_server_info_update.html @@ -22,6 +22,7 @@

Mainnet:

    +
  • fulcrum.sethforprivacy.com / s50002
  • electrum.emzy.de / s50002
  • electrum.blockstream.info / s50002
diff --git a/django/templates/encryption.html b/django/templates/encryption.html index be512f6..c925c51 100644 --- a/django/templates/encryption.html +++ b/django/templates/encryption.html @@ -5,7 +5,7 @@ {% block nav_home %}active{% endblock %} {% block content %} - +

Lbelbase

All your labels in one place.

@@ -18,11 +18,8 @@
-
- ... +
+

Your labels are encrypted.
 

diff --git a/django/templates/fill_missing_data.html b/django/templates/fill_missing_data.html new file mode 100644 index 0000000..b1d3f93 --- /dev/null +++ b/django/templates/fill_missing_data.html @@ -0,0 +1,128 @@ +{% extends "_base.html" %} +{% load i18n %} +{% load labelbase_tags %} +{% load sekizai_tags %} + +{% block content %} + +{% if labelbase %} +
+
+

Fill Missing Data - {{ labelbase.name }}

+ {% if labelbase.fingerprint %}{{ labelbase.fingerprint }}{% endif %} + {% if labelbase.about %} +

{{ labelbase.about }}

+ {% endif %} +
+
+ +
+

+ Auto-populate BIP-329 additional fields (height, time, value) for outputs from OutputStat data. +

+ + {% if can_fill_from_outputstat %} +
+ {{ can_fill_from_outputstat|length }} output labels can be filled! +
+ {% else %} +
+ All good! All {{ already_complete|length }} output labels have their fields populated. +
+ {% endif %} + + + {% if can_fill_from_outputstat %} +
+
+
+ {{ can_fill_from_outputstat|length }} Output labels can be filled from OutputStat + +
+

These output labels have OutputStat data available. Can populate: height, time, value.

+
+
+
+
    + {% for item in can_fill_from_outputstat %} +
  • +
    +
    + {{ item.label.get_type_display }}: {{ item.label.ref }}
    + Label: {{ item.label.label|default:"(empty)" }}
    + Missing fields: + {{ item.missing_fields|join:", " }}
    + Available data: + + height={{ item.output_stat.confirmed_at_block_height }}, + time={{ item.output_stat.confirmed_at_block_time }}, + value={{ item.output_stat.value }} sats + +
    +
    +
    + {% csrf_token %} + + + +
    + Edit +
    +
    +
  • + {% endfor %} +
+
+ {% csrf_token %} + + +
+
+
+
+ {% endif %} + + + {% if already_complete %} +
+
+
+ ✓ {{ already_complete|length }} Output labels already have all fields populated + +
+

These output labels have all applicable BIP-329 additional fields (height, time, value) populated.

+
+
+
+
    + {% for label in already_complete %} +
  • +
    +
    + {{ label.get_type_display }}: {{ label.ref }}
    + {{ label.label }} +
    +
    + Edit +
    +
    +
  • + {% endfor %} +
+
+
+
+ {% endif %} + +
+ +{% else %} +

Labelbase not found.

+{% endif %} + + + +{% endblock %} diff --git a/django/templates/home.html b/django/templates/home.html index b69639f..e49663e 100644 --- a/django/templates/home.html +++ b/django/templates/home.html @@ -268,27 +268,11 @@
-

Cloud-Hosted

-

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.

+

Attachments

+

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.

- {% comment %} -
-
-

Customized Email Sending

-

Personalize email sending with custom server settings. Labelbase enables users to configure preferred SMTP and IMAP options, enhancing control, privacy and security.

-
-
- {% endcomment %} - -
-
-

On-demand Support Chat

-

We are happy to assist you with the integrated on-demand support chat by Chatwoot. -
Privacy is ensured as the chat script loads only when you request it, which is totally optional.

-
-
- +

Automated Output Management

@@ -303,6 +287,47 @@
+
+
+

BIP-329 Extended Fields

+

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.

+
+
+ +
+
+

Currency Sync Tool

+

Seamlessly synchronize between legacy currency annotations in label text and structured FMV fields. Automatically detect and resolve conflicts to maintain data consistency across formats.

+
+
+ +
+
+

Bulk Auto-Fill Missing Data

+

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.

+
+
+ +
+
+

One-Click BIP-329 Field Population

+

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.

+
+
+ + +
+
+

Fee Health Monitoring

+

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.

+
+
+
+
+

UTXO Fee Efficiency Visualization

+

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.

+
+
diff --git a/django/templates/knowledge_base/article_detail.html b/django/templates/knowledge_base/article_detail.html index 4ae51cc..8f660bc 100644 --- a/django/templates/knowledge_base/article_detail.html +++ b/django/templates/knowledge_base/article_detail.html @@ -53,7 +53,8 @@ code { {% endfor %} +
{{ article.content | markdown | safe }} - +
{% endblock %} diff --git a/django/templates/knowledge_base/category_detail.html b/django/templates/knowledge_base/category_detail.html index febd64f..3618cc4 100644 --- a/django/templates/knowledge_base/category_detail.html +++ b/django/templates/knowledge_base/category_detail.html @@ -8,8 +8,7 @@ {#% block nav_home %}active{% endblock %#} {% block content %} - - +
{% breadcrumbs_category category as current_breadcrumbs %}
{% endblock %} diff --git a/django/templates/knowledge_base/index.html b/django/templates/knowledge_base/index.html index 2cedc8e..5dc6140 100644 --- a/django/templates/knowledge_base/index.html +++ b/django/templates/knowledge_base/index.html @@ -5,7 +5,7 @@ {% load static %} {% load sekizai_tags %} {% block title %}Knowledge Base Categories | Labelbase{% endblock %} -{#% block nav_home %}active{% endblock %#} + {% block content %} diff --git a/django/templates/label_derive_addresses.html b/django/templates/label_derive_addresses.html new file mode 100644 index 0000000..76be5ef --- /dev/null +++ b/django/templates/label_derive_addresses.html @@ -0,0 +1,171 @@ +{% extends "label_edit.html" %} +{% load bootstrap %} +{% load sekizai_tags %} +{% load i18n %} +{% load labelbase_tags %} + + +{% block label_edit_content %} + +{% include "_modal_add_label.html" %} + + + + + +
+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + {{ form.instance.ref }} + +
+ + + +
+ + + + + + + + + + + + +
+ +
+ +
+ + + + + + + +{% endblock %} diff --git a/django/templates/label_edit.html b/django/templates/label_edit.html index ae6f434..5830f6f 100644 --- a/django/templates/label_edit.html +++ b/django/templates/label_edit.html @@ -5,106 +5,71 @@ {% 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 %} - +
@@ -124,83 +89,50 @@
{% endif %} - {% is_label_id_in_queue object.id as is_in_queue %} - {% if is_in_queue %} -
- - Output in queue! This output is currently in the processing queue. It will be checked shortly. -
- {% else %} - - - {% if object.type == "output" %} - {% switch output.get_spent_status %} - {% case "spent" %} -
- Output spent! Blockchain records indicate that this output has been spent in another transaction. -
- {% case "unspent" %} -
- Output unspent! Blockchain records indicate that this output has not been spent yet. -
- {% case "unconfirmed" %} -
-
- - -
- Output unconfirmed! Blockchain records indicate that this output has not been confirmed yet. -
- {% endswitch %} - {% endif %} - - - - {% endif %} - - - - - - -{% comment %} -{% if object.type == "addr" %} -
- Heads up! There are multiple transaction outputs sent to this address. For privacy reasons, do not reuse addresses. +{% is_label_id_in_queue object.id as is_in_queue %} +{% if is_in_queue %} +
+ Output in queue! This output is currently in the processing queue. It will be checked shortly.
+{% else %} + {% if object.type == "output" %} + {% if output.get_spent_status == "spent" %} +
+ Output spent! Blockchain records indicate that this output has been spent in another transaction. +
+ {% elif output.get_spent_status == "unspent" %} +
+ Output unspent! Blockchain records indicate that this output has not been spent yet. +
+ {% elif output.get_spent_status == "unconfirmed" %} +
+
+ + +
+ Output unconfirmed! Blockchain records indicate that this output has not been confirmed yet. +
+ {% else %} +
+ Unknown status: The status of this output could not be determined. +
+ {% endif %} + {% endif %} {% endif %} -{% endcomment %} {% block label_edit_content %} {% endblock %} - - - - -
- - - - +
+ - {% endblock %} diff --git a/django/templates/label_edit_labeling.html b/django/templates/label_edit_labeling.html index 034cbcc..35416fc 100644 --- a/django/templates/label_edit_labeling.html +++ b/django/templates/label_edit_labeling.html @@ -168,6 +168,8 @@ - - + {% endif %} - - {% endblock %} diff --git a/django/templates/labelbase_tree_maps.html b/django/templates/labelbase_tree_maps.html index 36e80e1..b18d500 100644 --- a/django/templates/labelbase_tree_maps.html +++ b/django/templates/labelbase_tree_maps.html @@ -16,6 +16,12 @@ + diff --git a/django/templates/labelbase_tree_maps_unspent_outputs.html b/django/templates/labelbase_tree_maps_unspent_outputs.html index ceeb33a..326ceb2 100644 --- a/django/templates/labelbase_tree_maps_unspent_outputs.html +++ b/django/templates/labelbase_tree_maps_unspent_outputs.html @@ -11,14 +11,26 @@ - -{% if labelbase.is_testnet %} +{% if action == "fee-efficiency" %} +
+ +
+ Fee Efficiency (VTER): Color shows how efficient it is to spend each output based on the fee-to-value ratio. +

+ 🟢 Healthy: Fee < {{ request.user.profile.my_fee_threshold_healthy }}% of value
+ 🟡 Warning: Fee {{ request.user.profile.my_fee_threshold_healthy }}-{{ request.user.profile.my_fee_threshold_warning }}% of value
+ 🔴 High: Fee > {{ request.user.profile.my_fee_threshold_warning }}% of value +

+ Box size represents output value in sats. Thresholds based on your fee settings. Adjust in Profile → Fees +
+
+{% elif labelbase.is_testnet %}
Important Notice for Testnet Transactions: Testnet coins hold no real-world value and are solely intended for testing purposes.
-
{% else %}
@@ -28,6 +40,7 @@ If the UTXO value is not provided, Labelbase will estimate it based on historica
{% endif %} +