diff --git a/.gitignore b/.gitignore index 9ca9901..b2c78f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,6 @@ 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 deleted file mode 100644 index 59b71b8..0000000 --- a/BACKUP_AND_MIGRATION_GUIDE.md +++ /dev/null @@ -1,896 +0,0 @@ -# Labelbase Backup and Migration Guide - -A comprehensive guide for safely backing up your Labelbase database and running Django migrations. - -## Table of Contents -- [Why Backup?](#why-backup) -- [Quick Backup](#quick-backup) -- [Automated Backup Script](#automated-backup-script) -- [Running Migrations Safely](#running-migrations-safely) -- [Upgrading Labelbase](#upgrading-labelbase) -- [Restoring from Backup](#restoring-from-backup) -- [Scheduled Backups](#scheduled-backups) -- [Best Practices](#best-practices) - ---- - -## Why Backup? - -**Always backup before running migrations!** Migrations can: -- Alter database table structures in irreversible ways -- Delete data if there are bugs in the migration code -- Fail mid-execution, leaving your database inconsistent -- Introduce conflicts with existing data - -A backup takes 30 seconds. Recovery without one could take hours or days. - ---- - -## Quick Backup - -From your Labelbase directory: - -```bash -# Navigate to Labelbase directory -cd Labelbase - -# Source environment variables -source exports.sh - -# Create backup with timestamp -docker-compose exec -T labelbase_mysql mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" labelbase > backup_$(date +%Y%m%d_%H%M%S).sql -``` - -This creates a backup file like `backup_20250119_143022.sql`. - -### Backup config.ini (Important!) - -The `config.ini` file contains encryption keys and other critical settings. Always back it up too: - -```bash -# Navigate to Labelbase directory -cd Labelbase - -# Source environment variables -source exports.sh - -# Create backup with timestamp -docker-compose exec -T labelbase_django cat /app/config.ini > backup_$(date +%Y%m%d_%H%M%S)_config.ini -``` - -This creates a backup file like `backup_20250119_143022_config.ini`. - ---- - -## Automated Backup Script - -Create a reusable backup script that handles everything automatically. - -### Create the Script - -Save this as `backup-labelbase.sh` in your Labelbase directory: - -```bash -#!/bin/bash - -# Labelbase Database Backup Script -# Usage: ./backup-labelbase.sh - -# Configuration -LABELBASE_DIR="/path/to/Labelbase" # CHANGE THIS to your actual path -BACKUP_DIR="$LABELBASE_DIR/backups" -KEEP_BACKUPS=10 # Number of backups to keep - -# Colors for output -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Change to Labelbase directory -cd "$LABELBASE_DIR" || exit 1 - -# Source environment variables for MySQL passwords -if [ ! -f "exports.sh" ]; then - echo -e "${RED}✗ Error: exports.sh not found!${NC}" - echo "Make sure you're in the Labelbase directory and exports.sh exists." - exit 1 -fi - -source exports.sh - -# Check if MySQL password is set -if [ -z "$MYSQL_ROOT_PASSWORD" ]; then - echo -e "${RED}✗ Error: MYSQL_ROOT_PASSWORD not set!${NC}" - echo "Make sure exports.sh contains the MySQL password." - exit 1 -fi - -# Create backup directory if it doesn't exist -mkdir -p "$BACKUP_DIR" - -# Generate backup filename with timestamp -TIMESTAMP=$(date +%Y%m%d_%H%M%S) -BACKUP_FILE="$BACKUP_DIR/labelbase_backup_$TIMESTAMP.sql" -CONFIG_BACKUP_FILE="$BACKUP_DIR/config_backup_$TIMESTAMP.ini" - -echo -e "${YELLOW}Starting backup...${NC}" -echo "Database backup: $BACKUP_FILE" -echo "Config backup: $CONFIG_BACKUP_FILE" - -# Backup config.ini first (contains encryption keys!) -echo "Backing up config.ini..." -docker-compose exec -T labelbase_django cat /app/config.ini > "$CONFIG_BACKUP_FILE" - -if [ $? -eq 0 ] && [ -s "$CONFIG_BACKUP_FILE" ]; then - echo -e "${GREEN}✓ Config backup successful${NC}" -else - echo -e "${YELLOW}⚠ Config backup failed or file is empty${NC}" -fi - -# Create database backup -docker-compose exec -T labelbase_mysql mysqldump \ - -u root \ - -p"${MYSQL_ROOT_PASSWORD}" \ - --single-transaction \ - --quick \ - --lock-tables=false \ - labelbase > "$BACKUP_FILE" - -# Check if backup was successful -if [ $? -eq 0 ] && [ -s "$BACKUP_FILE" ]; then - echo -e "${GREEN}✓ Backup successful!${NC}" - - # Get file size - SIZE=$(du -h "$BACKUP_FILE" | cut -f1) - echo "Backup size: $SIZE" - - # Compress backup to save space - echo "Compressing backup..." - gzip "$BACKUP_FILE" - - if [ $? -eq 0 ]; then - COMPRESSED_SIZE=$(du -h "${BACKUP_FILE}.gz" | cut -f1) - echo -e "${GREEN}✓ Compressed to: $COMPRESSED_SIZE${NC}" - echo "Backup location: ${BACKUP_FILE}.gz" - else - echo -e "${YELLOW}⚠ Compression failed, keeping uncompressed backup${NC}" - fi - - # Clean up old backups (keep only last N backups) - echo "Cleaning up old backups (keeping last $KEEP_BACKUPS)..." - BACKUP_COUNT=$(ls -1 "$BACKUP_DIR"/labelbase_backup_*.sql.gz 2>/dev/null | wc -l) - - if [ "$BACKUP_COUNT" -gt "$KEEP_BACKUPS" ]; then - ls -t "$BACKUP_DIR"/labelbase_backup_*.sql.gz | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm - echo -e "${GREEN}✓ Cleaned up old backups${NC}" - else - echo "No cleanup needed ($BACKUP_COUNT backups exist)" - fi - - echo "" - echo -e "${GREEN}=== Backup Complete ===${NC}" - echo "Database: ${BACKUP_FILE}.gz" - echo "Config: ${CONFIG_BACKUP_FILE}" - -else - echo -e "${RED}✗ Backup failed!${NC}" - - # Remove empty or failed backup file - [ -f "$BACKUP_FILE" ] && rm "$BACKUP_FILE" - - echo "Troubleshooting:" - echo "1. Check if MySQL container is running: docker-compose ps" - echo "2. Check MySQL logs: docker-compose logs labelbase_mysql" - echo "3. Verify password in exports.sh" - exit 1 -fi -``` - -### Make Script Executable - -```bash -chmod +x backup-labelbase.sh -``` - -### Edit Configuration - -Open `backup-labelbase.sh` and change this line to your actual Labelbase path: - -```bash -LABELBASE_DIR="/path/to/Labelbase" # CHANGE THIS! -``` - -For example: -```bash -LABELBASE_DIR="/root/Labelbase" -# or -LABELBASE_DIR="/home/username/Labelbase" -``` - -### Run the Backup - -```bash -./backup-labelbase.sh -``` - -You'll see output like: -``` -Starting backup... -Backup file: /path/to/Labelbase/backups/labelbase_backup_20250119_143022.sql -✓ Backup successful! -Backup size: 15M -Compressing backup... -✓ Compressed to: 3.2M -Backup location: /path/to/Labelbase/backups/labelbase_backup_20250119_143022.sql.gz -Cleaning up old backups (keeping last 10)... -No cleanup needed (3 backups exist) - -=== Backup Complete === -Location: /path/to/Labelbase/backups/labelbase_backup_20250119_143022.sql.gz -``` - ---- - -## Running Migrations Safely - -**Always follow this order:** - -### Step 1: Create a Backup - -```bash -./backup-labelbase.sh -``` - -### Step 2: Check Migration Status - -```bash -source exports.sh -docker-compose exec labelbase_django python manage.py showmigrations -``` - -This shows which migrations are applied (marked with `[X]`) and pending (marked with `[ ]`). - -### Step 3: Review Pending Migrations - -Look for any unapplied migrations. If you see pending migrations for critical apps, review them carefully. - -### Step 4: Apply Migrations - -```bash -# If you've modified models, create new migrations first -docker-compose exec labelbase_django python manage.py makemigrations - -# Apply all pending migrations -docker-compose exec labelbase_django python manage.py migrate -``` - -### Step 5: Verify Application - -After migrations complete: -1. Check for any error messages -2. Visit your Labelbase site -3. Test critical functionality -4. Check Django logs: `docker-compose logs labelbase_django` - -### Step 6: If Something Goes Wrong - -If migrations fail or break functionality, restore from backup (see below). - ---- - -## Upgrading Labelbase - -When new versions of Labelbase are released, follow this workflow to safely upgrade. - -### Complete Upgrade Workflow - -**Step 1: Backup First (Critical!)** - -```bash -cd Labelbase -./backup-labelbase.sh -``` - -**Step 2: Pull Latest Code** - -```bash -git pull origin master -``` - -**Step 3: Rebuild Containers (if needed)** - -If dependencies or Docker configuration changed: - -```bash -source exports.sh && docker-compose up --build -d -``` - -Or use the main script: - -```bash -source exports.sh && ./build-and-run-labelbase.sh -``` - -**⚠️ IMPORTANT**: These commands are SAFE - they rebuild containers but preserve your data in Docker volumes. Your database and uploaded files are NOT deleted. - -**❌ DANGER ZONE - Commands that DELETE data:** -```bash -# NEVER run these unless you want to lose ALL data: -docker-compose down -v # The -v flag deletes volumes = data loss! -docker volume prune # Deletes unused volumes -docker system prune -a # Nuclear option - deletes everything -``` - -**Step 4: Apply Migrations and collect static files ** - -```bash -source exports.sh -docker-compose exec labelbase_django python manage.py showmigrations -docker-compose exec labelbase_django python manage.py migrate -docker-compose exec labelbase_django python manage.py collectstatic --noinput - -``` - -**Step 5: Restart Services** - -```bash -docker-compose restart labelbase_django -``` - -**Step 6: Verify Everything Works** - -1. Visit your Labelbase site -2. Test critical functionality -3. Check logs: `docker-compose logs -f labelbase_django` - -### Quick Upgrade Script - -Create `update-and-migrate.sh` for a streamlined upgrade process: - -```bash -#!/bin/bash - -# Labelbase Quick Update & Migration Script -# Usage: source exports.sh && ./update-and-migrate.sh - -# Colors -GREEN='\033[0;32m' -RED='\033[0;31m' -YELLOW='\033[1;33m' -NC='\033[0m' - -echo -e "${YELLOW}=== Labelbase Update & Migrate ===${NC}" - -# Check if we're in the right directory -if [ ! -f "docker-compose.yml" ]; then - echo -e "${RED}Error: Not in Labelbase directory (docker-compose.yml not found)${NC}" - exit 1 -fi - -# Check env vars -if [[ -z "${MYSQL_ROOT_PASSWORD}" ]]; then - echo -e "${RED}Error: Run 'source exports.sh' first!${NC}" - exit 1 -fi - -# Step 1: Backup -echo -e "${YELLOW}Step 1: Creating backup...${NC}" -if [ -f "backup-labelbase.sh" ]; then - ./backup-labelbase.sh - if [ $? -ne 0 ]; then - echo -e "${RED}Backup failed! Aborting upgrade.${NC}" - exit 1 - fi -else - echo -e "${YELLOW}⚠ Warning: backup-labelbase.sh not found, skipping backup${NC}" - read -p "Continue without backup? (yes/no): " -r - if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then - echo "Upgrade cancelled." - exit 0 - fi -fi - -# Step 2: Pull latest code -echo "" -echo -e "${YELLOW}Step 2: Pulling latest changes...${NC}" -git pull origin master - -if [ $? -ne 0 ]; then - echo -e "${RED}Git pull failed!${NC}" - exit 1 -fi - -# Step 3: Check for pending migrations -echo "" -echo -e "${YELLOW}Step 3: Checking for migrations...${NC}" -PENDING=$(docker-compose exec -T labelbase_django python manage.py showmigrations --plan 2>/dev/null | grep "\[ \]" | wc -l) - -if [ $PENDING -gt 0 ]; then - echo -e "${YELLOW}Found $PENDING pending migration(s)${NC}" - - # Show what will be migrated - echo "Pending migrations:" - docker-compose exec -T labelbase_django python manage.py showmigrations | grep "\[ \]" - - echo "" - read -p "Apply migrations now? (y/n): " -n 1 -r - echo - - if [[ $REPLY =~ ^[Yy]$ ]]; then - echo "Applying migrations..." - docker-compose exec -T labelbase_django python manage.py migrate --noinput - - if [ $? -eq 0 ]; then - echo -e "${GREEN}✓ Migrations applied successfully${NC}" - else - echo -e "${RED}✗ Migrations failed!${NC}" - echo "Check logs: docker-compose logs labelbase_django" - exit 1 - fi - else - echo -e "${YELLOW}⚠ Skipping migrations${NC}" - echo "Run manually later: docker-compose exec labelbase_django python manage.py migrate" - fi -else - echo -e "${GREEN}✓ No pending migrations${NC}" -fi - -# Step 4: Restart Django -echo "" -echo -e "${YELLOW}Step 4: Restarting Django...${NC}" -docker-compose restart labelbase_django - -echo "" -echo -e "${GREEN}=== Update Complete! ===${NC}" -echo "Check logs: docker-compose logs -f labelbase_django" -echo "Visit your site to verify everything works" -``` - -Make it executable: - -```bash -chmod +x update-and-migrate.sh -``` - -### Using the Quick Upgrade Script - -```bash -# Navigate to Labelbase -cd Labelbase - -# Source environment and run update -source exports.sh && ./update-and-migrate.sh -``` - -The script will: -1. ✓ Create automatic backup -2. ✓ Pull latest code from git -3. ✓ Detect pending migrations -4. ✓ Ask for confirmation before applying -5. ✓ Restart services -6. ✓ Provide verification steps - -### When to Rebuild vs. Restart - -**Just restart** (`docker-compose restart`) when: -- Only Django code changed (Python files) -- No dependency updates -- No Dockerfile changes -- Fastest option - -**Full rebuild** (`docker-compose up --build -d`) when: -- requirements.txt changed -- Dockerfile modified -- New system packages needed -- Docker configuration changed - -**Data Safety Note**: Both `restart` and `--build` are SAFE - they preserve your data. Docker stores your database and files in **volumes** that persist across rebuilds. - -If unsure, rebuild - it's safer and only takes a minute longer. - -### What Actually Deletes Data - -Only these commands delete data (requires `-v` flag): - -```bash -# DANGER: This deletes ALL data including database! -docker-compose down -v - -# To safely stop without deleting data, use: -docker-compose down # Safe - keeps volumes -docker-compose stop # Safe - just stops containers -``` - -**Rule of thumb**: If you see `-v` flag, your data is at risk! - -### Rollback After Failed Upgrade - -If something goes wrong: - -```bash -# 1. Stop services -docker-compose down - -# 2. Restore previous code -git reset --hard HEAD~1 - -# 3. Restore database -./restore-labelbase.sh backups/labelbase_backup_TIMESTAMP.sql.gz - -# 4. Restart -source exports.sh && docker-compose up -d -``` - ---- - -## Restoring from Backup - -If something goes wrong, you can restore your database from a backup. - -### Quick Restore - -```bash -# Navigate to Labelbase directory -cd Labelbase - -# Source environment variables -source exports.sh - -# Stop Django to prevent conflicts -docker-compose stop labelbase_django - -# Decompress and restore backup -gunzip -c backups/labelbase_backup_20250119_143022.sql.gz | \ - docker-compose exec -T labelbase_mysql mysql -u root -p"${MYSQL_ROOT_PASSWORD}" labelbase - -# Restart all services -docker-compose up -d - -# Check logs -docker-compose logs -f labelbase_django -``` - -### Restore Script (Optional) - -Create `restore-labelbase.sh`: - -```bash -#!/bin/bash - -# Labelbase Database Restore Script -# Usage: ./restore-labelbase.sh - -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 deleted file mode 100644 index ddc2c0c..0000000 --- a/BARE_METALL_INSTALL.md +++ /dev/null @@ -1,357 +0,0 @@ -# Labelbase Bare Metal Installation Guide - -This guide will help you install and run Labelbase directly on macOS and Linux systems, based on the RaspiBlitz installation script. - -## Prerequisites - -### Common Requirements -- Git -- Python 3.8 or higher -- pip (Python package manager) -- virtualenv -- MySQL/MariaDB server - -### System-Specific Requirements - -#### macOS -```bash -# Install Homebrew if not already installed -/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - -# Install required packages -brew install python3 mysql git -brew install pkg-config mysql-client -``` - -#### Linux (Ubuntu/Debian) -```bash -# Update package list -sudo apt update - -# Install required packages -sudo apt install -y \ - mariadb-server \ - mariadb-client \ - default-libmysqlclient-dev \ - build-essential \ - python3 \ - python3-pip \ - python3-venv \ - git \ - libpcre3-dev -``` - -#### Linux (CentOS/RHEL/Fedora) -```bash -# For CentOS/RHEL 8+ -sudo dnf install -y \ - mariadb-server \ - mariadb-devel \ - python3 \ - python3-pip \ - python3-virtualenv \ - git \ - gcc \ - gcc-c++ \ - make - -# For older versions, use yum instead of dnf -``` - -## Installation Steps - -### 1. Create Application User (Optional but Recommended) - -#### macOS -```bash -# Create a new user (optional on macOS for development) -sudo dscl . -create /Users/labelbase -sudo dscl . -create /Users/labelbase UserShell /bin/bash -sudo dscl . -create /Users/labelbase RealName "Labelbase User" -sudo dscl . -create /Users/labelbase UniqueID 1001 -sudo dscl . -create /Users/labelbase PrimaryGroupID 20 -sudo dscl . -create /Users/labelbase NFSHomeDirectory /Users/labelbase -sudo createhomedir -c -u labelbase -``` - -#### Linux -```bash -# Create system user -sudo adduser --system --group --shell /bin/bash --home /home/labelbase labelbase -sudo -u labelbase cp -r /etc/skel/. /home/labelbase/ -``` - -### 2. Start Database Service - -#### macOS -```bash -# Start MySQL service -brew services start mysql - -# Secure the installation -mysql_secure_installation -``` - -#### Linux -```bash -# Start MariaDB service -sudo systemctl enable mariadb -sudo systemctl start mariadb - -# Secure the installation -sudo mysql_secure_installation -``` - -### 3. Download and Setup Labelbase - -```bash -# Switch to labelbase user (if created) or use your regular user -# sudo su - labelbase # (if using dedicated user) - -# Set variables -LABELBASE_HOME="$HOME" # or /home/labelbase if using dedicated user -LABELBASE_REPO="https://github.com/Labelbase/Labelbase/" -LABELBASE_VERSION="2.2.1" - -# Clone the repository -git clone $LABELBASE_REPO $LABELBASE_HOME/labelbase -cd $LABELBASE_HOME/labelbase - -# Checkout specific version -git checkout $LABELBASE_VERSION - -# Create virtual environment -python3 -m venv $LABELBASE_HOME/ENV - -# Activate virtual environment -source $LABELBASE_HOME/ENV/bin/activate - -# Install Python dependencies -pip install --upgrade pip -pip install --no-cache-dir -r $LABELBASE_HOME/labelbase/django/requirements.txt -``` - -### 4. Database Configuration - -```bash -# Generate a secure password -MYSQL_PASSWORD=$(openssl rand -base64 32 | tr -d '+/' | fold -w 32 | head -n 1) - -# Create exports file -cat > $LABELBASE_HOME/exports.sh << EOF -export MYSQL_PASSWORD=$MYSQL_PASSWORD -export DATABASE_URL=mysql://ulabelbase:$MYSQL_PASSWORD@localhost:3306/labelbase -EOF - -chmod 755 $LABELBASE_HOME/exports.sh -``` - -#### Create Database and User - -##### macOS -```bash -# Connect to MySQL -mysql -u root -p - -# In MySQL prompt: -CREATE DATABASE labelbase; -CREATE USER 'ulabelbase'@'localhost' IDENTIFIED BY 'YOUR_GENERATED_PASSWORD'; -GRANT ALL PRIVILEGES ON labelbase.* TO 'ulabelbase'@'localhost'; -FLUSH PRIVILEGES; -EXIT; -``` - -##### Linux -```bash -# Connect to MariaDB -sudo mysql - -# In MariaDB prompt: -CREATE DATABASE labelbase; -CREATE USER 'ulabelbase'@'localhost' IDENTIFIED BY 'YOUR_GENERATED_PASSWORD'; -GRANT ALL PRIVILEGES ON labelbase.* TO 'ulabelbase'@'localhost'; -FLUSH PRIVILEGES; -EXIT; -``` - -**Note:** Replace `YOUR_GENERATED_PASSWORD` with the password generated in the previous step. - -### 5. Django Setup - -```bash -# Navigate to Django directory -cd $LABELBASE_HOME/labelbase/django - -# Activate virtual environment and load environment variables -source $LABELBASE_HOME/ENV/bin/activate -source $LABELBASE_HOME/exports.sh - -# Run Django migrations -python manage.py makemigrations --noinput -python manage.py migrate --noinput -python manage.py collectstatic --noinput - -# Create a superuser (optional) -python manage.py createsuperuser -``` - -### 6. Running Labelbase - -#### Development Mode -```bash -# Activate environment -source $LABELBASE_HOME/ENV/bin/activate -source $LABELBASE_HOME/exports.sh - -# Navigate to Django directory -cd $LABELBASE_HOME/labelbase/django - -# Run development server -python manage.py runserver 0.0.0.0:8089 - -# Access at: http://localhost:8089 -``` - -#### Production Mode (using Gunicorn) -```bash -# Install Gunicorn if not already installed -pip install gunicorn - -# Run with Gunicorn -source $LABELBASE_HOME/ENV/bin/activate -source $LABELBASE_HOME/exports.sh -cd $LABELBASE_HOME/labelbase/django - -gunicorn labellabor.wsgi:application -b 0.0.0.0:8089 --reload -``` - -## Creating System Services (Optional) - -### macOS (using LaunchAgent) - -Create a plist file at `~/Library/LaunchAgents/com.labelbase.app.plist`: - -```xml - - - - - 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 deleted file mode 100644 index 18baa1a..0000000 --- a/DEVELOPMENT_GUIDE.md +++ /dev/null @@ -1,312 +0,0 @@ -# Labelbase Development Guide - -Quick reference for working with and developing Labelbase in Docker. - -## Initial Setup - -### 1. Clone and Setup -```bash -git clone https://github.com/Labelbase/Labelbase/ -cd Labelbase -``` - -### 2. Generate MySQL Passwords -```bash -./make-exports.sh -``` - -This creates `exports.sh` with random passwords. **Backup this file!** - -### 3. Build and Run -```bash -source exports.sh && ./build-and-run-labelbase.sh -``` - -The `source exports.sh` loads the passwords into your shell, then the script uses them. - -Access at: http://127.0.0.1:8080 - ---- - -## Daily Development Workflow - -### Start/Stop Services -```bash -# Start (always source exports.sh first!) -source exports.sh && docker-compose up -d - -# Stop -docker-compose down - -# Rebuild and restart (after code changes) -source exports.sh && docker-compose up --build -d - -# Or use the main script -source exports.sh && ./build-and-run-labelbase.sh -``` - -### View Logs -```bash -# All services -docker-compose logs -f - -# Specific service -docker-compose logs -f labelbase_django -docker-compose logs -f labelbase_mysql -docker-compose logs -f labelbase_nginx - -# Search logs -docker-compose logs labelbase_django | grep -i error -``` - -### Access Container Shell -```bash -# Django container (most common) -docker-compose exec labelbase_django bash - -# MySQL container -docker-compose exec labelbase_mysql bash - -# Nginx container -docker-compose exec labelbase_nginx sh -``` - -### Django Management Commands -```bash -# From host -docker-compose exec labelbase_django python manage.py - -# 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 b00af70..bfa4ec3 100755 --- a/build-and-run-labelbase.sh +++ b/build-and-run-labelbase.sh @@ -7,19 +7,17 @@ docker-compose down # make sure Labelbase is terminated. if [[ -z "${MYSQL_ROOT_PASSWORD}" ]]; then echo "Error: MYSQL_ROOT_PASSWORD environment variable is not set" exit 1 +else + export $MYSQL_ROOT_PASSWORD fi if [[ -z "${MYSQL_PASSWORD}" ]]; then echo "Error: MYSQL_PASSWORD environment variable is not set" exit 1 +else + export $MYSQL_PASSWORD fi - -export MYSQL_ROOT_PASSWORD -export MYSQL_PASSWORD - - - # Check git next LAST_GIT_COMMIT_FILE=".last_git_commit" diff --git a/django/Dockerfile b/django/Dockerfile index 5b90b98..2e9f23c 100644 --- a/django/Dockerfile +++ b/django/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11 +FROM python:3.9 ENV PYTHONUNBUFFERED 1 @@ -11,7 +11,7 @@ RUN apt-get update && \ default-libmysqlclient-dev \ build-essential \ cron vim logrotate \ - libpcre2-dev \ + libpcre3-dev \ default-mysql-client \ && rm -rf /var/lib/apt/lists/* \ && pip install --upgrade pip \ diff --git a/django/attachments/django-attachments-1.11.zip b/django/attachments/django-attachments-1.11.zip new file mode 100644 index 0000000..ea2e0b9 Binary files /dev/null and b/django/attachments/django-attachments-1.11.zip differ diff --git a/django/attachments/models.py b/django/attachments/models.py index 2f107cf..54c1d06 100644 --- a/django/attachments/models.py +++ b/django/attachments/models.py @@ -15,12 +15,19 @@ import logging logger = logging.getLogger('labelbase') def attachment_upload(instance, filename): - pass # used for compatibility / migrations only. + """Stores the attachment in a "per module/appname/primary key" folder""" + return "attachments/{app}_{model}/{pk}/{filename}".format( + app=instance.content_object._meta.app_label, + model=instance.content_object._meta.object_name.lower(), + pk=instance.content_object.pk, + filename=filename, + ) class AttachmentManager(models.Manager): def attachments_for_object(self, obj): object_type = ContentType.objects.get_for_model(obj) + print ("x attachments_for_object id {} , {} {}".format( obj.pk, object_type, object_type.id )) return self.filter(content_type__pk=object_type.id, object_id=obj.pk) @@ -28,7 +35,6 @@ class AttachmentManager(models.Manager): class Attachment(models.Model): objects = AttachmentManager() - content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.CharField(db_index=True, max_length=64) content_object = GenericForeignKey("content_type", "object_id") @@ -39,7 +45,7 @@ class Attachment(models.Model): on_delete=models.CASCADE, ) attachment_file = models.FileField( - _("attachment"), upload_to=upload_to + _("attachment"), upload_to=upload_to # attachment_upload ) created = models.DateTimeField(_("created"), auto_now_add=True, db_index=True) modified = models.DateTimeField(_("modified"), auto_now=True, db_index=True) @@ -77,7 +83,7 @@ class Attachment(models.Model): if update_path: old_path = self.attachment_file.path - self.attachment_file.name = upload_to + self.attachment_file.name = upload_to # attachment_upload(self, self.filename) self.attachment_file.save() os.makedirs( diff --git a/django/attachments/templatetags/attachments_tags.py b/django/attachments/templatetags/attachments_tags.py index 6580fef..e5a8633 100644 --- a/django/attachments/templatetags/attachments_tags.py +++ b/django/attachments/templatetags/attachments_tags.py @@ -22,24 +22,38 @@ def attachment_form(context, obj, **kwargs): The user must own ``attachments.add_attachment permission`` to add attachments. """ - - return { - "form": AttachmentForm(), - "form_url": add_url_for_obj(obj), - "next": context.request.path, - } + if context["user"].has_perm("attachments.add_attachment"): + return { + "form": AttachmentForm(), + "form_url": add_url_for_obj(obj), + "next": context.request.path, + } + else: + return {"form": None} @register.inclusion_tag("attachments/delete_link.html", takes_context=True) def attachment_delete_link(context, attachment, **kwargs): - if context["user"] == attachment.creator: + """ + Renders a html link to the delete view of the given attachment. Returns + no content if the request-user has no permission to delete attachments. + + The user must own either the ``attachments.delete_attachment`` permission + and is the creator of the attachment, that he can delete it or he has + ``attachments.delete_foreign_attachments`` which allows him to delete all + attachments. + """ + if context["user"].has_perm("attachments.delete_foreign_attachments") or ( + context["user"] == attachment.creator + and context["user"].has_perm("attachments.delete_attachment") + ): return { "next": context.request.path, "delete_url": reverse( "attachments:delete", kwargs={"attachment_pk": attachment.pk} ), } - + return {"delete_url": None} @register.simple_tag diff --git a/django/attachments/views.py b/django/attachments/views.py index c6938eb..77f7ddb 100644 --- a/django/attachments/views.py +++ b/django/attachments/views.py @@ -49,6 +49,10 @@ def add_attachment( extra_context=None, ): next_ = request.POST.get("next", "/") + + if not request.user.has_perm("attachments.add_attachment"): + return HttpResponseRedirect(next_) + model = apps.get_model(app_label, model_name) obj = get_object_or_404(model, pk=pk) obj = obj.get_label_attachment() # our label to attachment proxy @@ -67,13 +71,17 @@ def add_attachment( "next": next_, } template_context.update(extra_context or {}) + return render(request, template_name, template_context) @login_required def delete_attachment(request, attachment_pk): g = get_object_or_404(Attachment, pk=attachment_pk) - if request.user == g.creator: + if ( + request.user.has_perm("attachments.delete_attachment") + and request.user == g.creator + ) or request.user.has_perm("attachments.delete_foreign_attachments"): remove_file_from_disk(g.attachment_file) g.delete() messages.success(request, gettext("Your attachment was deleted.")) diff --git a/django/background_task/tasks.py b/django/background_task/tasks.py index 409cea6..80a1248 100644 --- a/django/background_task/tasks.py +++ b/django/background_task/tasks.py @@ -38,6 +38,7 @@ def bg_runner(proxy_task, task=None, loop=None, *args, **kwargs): task = task_qs[0] if func is None: raise BackgroundTaskError("Function is None, can't execute!") + print("bg_runner, loop {}".format(loop)) kwargs['loop'] = loop func(*args, **kwargs) @@ -95,6 +96,8 @@ class Tasks(object): return _decorator def run_task(self, task_name, loop, args=None, kwargs=None): + print("run_task loop {}".format(loop)) + # task_name can be either the name of a task or a Task instance. if isinstance(task_name, Task): task = task_name task_name = task.task_name diff --git a/django/background_task/templatetags/backgroundtask_tags.py b/django/background_task/templatetags/backgroundtask_tags.py index 1b7249d..267f055 100644 --- a/django/background_task/templatetags/backgroundtask_tags.py +++ b/django/background_task/templatetags/backgroundtask_tags.py @@ -5,8 +5,5 @@ register = template.Library() @register.simple_tag def is_label_id_in_queue(label_id): - try: - return Task.objects.filter(task_name="finances.tasks.check_spent", - task_params__contains=label_id).exists() - except: - return False + return Task.objects.filter(task_name="finances.tasks.check_spent", + task_params__contains=label_id).exists() diff --git a/django/finances/admin.py b/django/finances/admin.py index ba6f402..766fba6 100644 --- a/django/finances/admin.py +++ b/django/finances/admin.py @@ -2,16 +2,7 @@ from django.contrib import admin from .models import OutputStat, HistoricalPrice class OutputStatAdmin(admin.ModelAdmin): - list_display = ('type_ref_hash', - 'value', - 'confirmed_at_block_height', - 'confirmed_at_block_time', - 'get_spent_status', - 'spent', - 'network', - 'user', - 'next_enc_input_attrs', - 'last_error') + list_display = ('type_ref_hash', 'value', 'confirmed_at_block_height', 'confirmed_at_block_time', 'get_spent_status', 'spent', 'network', 'user') list_filter = ('network', 'spent') search_fields = ('type_ref_hash',) ordering = ('-confirmed_at_block_time',) diff --git a/django/finances/electrum.py b/django/finances/electrum.py index 2c8c04f..bb8459b 100644 --- a/django/finances/electrum.py +++ b/django/finances/electrum.py @@ -1,32 +1,36 @@ from connectrum.client import StratumClient from connectrum.svr_info import ServerInfo from connectrum import ElectrumErrorResponse + + from labelbase.models import Label from finances.models import OutputStat, HistoricalPrice -import logging +import logging logger = logging.getLogger('labelbase') + async def interact(conn, server_info, method, utxo): try: await conn.connect(server_info, "s", use_tor=server_info.is_onion, - disable_cert_verify=True, short_term=True) + disable_cert_verify=True, short_term=True) txid, index = utxo.split(":") try: txn = await conn.RPC(method, txid, True) if txn: try: blocktime = int(txn.get('blocktime', 0)) - logger.debug(f"blocktime: {blocktime}") + logger.debug("blocktime: {}".format(blocktime)) except Exception as ex: blocktime = 0 - logger.error(f"Can't get blocktime: {ex}") + logger.error("Can't get blocktime: {}".format(ex)) utxo = txn.get('vout')[int(index)] address = txn.get('vout')[int(index)].get('scriptPubKey', {}).get('address') - value = txn.get('vout')[int(index)].get('value') * 100000000 - return txid, index, address, value, blocktime, utxo + value = txn.get('vout')[int(index)].get('value')*100000000 + return (txid, index, address, value, blocktime, utxo) except ElectrumErrorResponse as ex: - logger.error(f"ERROR: {ex} {conn.last_error}") + logger.error("ERROR: {} {}".format(ex, conn.last_error)) + finally: conn.close() @@ -34,117 +38,198 @@ async def interact(conn, server_info, method, utxo): async def interact_addr(conn, server_info, method, addr): try: await conn.connect(server_info, "s", use_tor=server_info.is_onion, - disable_cert_verify=True, short_term=True) + disable_cert_verify=True, short_term=True) try: hextx = await conn.RPC(method, addr) if hextx is not None: + print(hextx) return hextx + else: + print("Failed to fetch transaction.") except ElectrumErrorResponse as ex: - logger.error(ex) + print(ex) finally: conn.close() - def is_valid_output_ref(ref): - return ":" in ref if ref else False - + if not ref: + return False + if ":" in ref: + return True + return False def checkup_label(label_id, loop): - if not label_id or not loop: - logger.error(f"Invalid input: label_id={label_id}, loop={loop}") - return + if label_id and loop: + try: + elem = Label.objects.get(id=label_id) + output = OutputStat.objects.filter(user=elem.labelbase.user, + type_ref_hash=elem.type_ref_hash, + network=elem.labelbase.network).last() + if not output: + output = OutputStat(user=elem.labelbase.user, + type_ref_hash=elem.type_ref_hash, + network=elem.labelbase.network, value=0) - try: + if elem.type == "output" and is_valid_output_ref(elem.ref) and \ + (output.spent is not True or output.confirmed_at_block_time == 0): + if elem.labelbase.is_mainnet: + electrum_hostname = elem.labelbase.user.profile.electrum_hostname or "electrum.emzy.de" + electrum_ports = elem.labelbase.user.profile.electrum_ports or "s50002" + elif elem.labelbase.is_testnet: + electrum_hostname = elem.labelbase.user.profile.electrum_hostname_test or "testnet.qtornado.com" + electrum_ports = elem.labelbase.user.profile.electrum_ports_test or "s51002" + server_info = ServerInfo(electrum_hostname, electrum_hostname, ports=(electrum_ports)) + + conn = StratumClient() + utxo = elem.ref + utxo_data = {} + utxo_resp = loop.run_until_complete(interact(conn, server_info, "blockchain.transaction.get", utxo)) + + if utxo_resp: + txid, index, address, value, blocktime, utxo_data = utxo_resp + if utxo_data: + output.next_input_attributes = utxo_data + if blocktime: + HistoricalPrice.get_or_create_from_api(timestamp=blocktime) + + try: + unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.address.listunspent", address)) + except: + conn.last_error = None # reset error if needed + unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.scripthash.listunspent", address)) + + + + + utxo_value = 0 + utxo_height = 0 + + if unspents: + for unspent in unspents: + if unspent.get('tx_hash') == txid and \ + unspent.get('tx_pos') == int(index) and \ + unspent.get('height') > 0 and \ + unspent.get('value') > 0: # Output is confirmed, but not spent yet + output.spent = False + utxo_value = unspent.get('value') + utxo_height = unspent.get('height') + + output.network = elem.labelbase.network + if utxo_height: + output.confirmed_at_block_height = utxo_height + if blocktime: + output.confirmed_at_block_time = blocktime + if utxo_value: + output.value = utxo_value + elif value: + output.value = value + break + # + elif conn.last_error: + output.last_error = conn.last_error + else: + output.last_error = {} + output.save() + try: + conn.close() + except: + pass + + except Exception as e: + logger.error("Error processing label {}: {}".format(label_id, e)) + else: + if not label_id: + logger.error("Can't get label_id! {}".format(label_id)) + if not loop: + logger.error("Can't get loop!") + + +def checkup_label_buggy(label_id, loop): + if label_id and loop: elem = Label.objects.get(id=label_id) - output = OutputStat.objects.filter( - user=elem.labelbase.user, - type_ref_hash=elem.type_ref_hash, - network=elem.labelbase.network - ).last() - + output = OutputStat.objects.filter(user=elem.labelbase.user, + type_ref_hash=elem.type_ref_hash, + network=elem.labelbase.network).last() if not output: - output = OutputStat( - user=elem.labelbase.user, - type_ref_hash=elem.type_ref_hash, - network=elem.labelbase.network, - value=0, - spent=None, - confirmed_at_block_height=0, - confirmed_at_block_time=0 - ) - output.save() - logger.debug(f"Output before processing: {output.output_metrics_dict()}") - - if elem.type == "output" and is_valid_output_ref(elem.ref) and ( - output.spent is not True or output.confirmed_at_block_time is None - ): - # Determine server info based on network - if elem.labelbase.is_mainnet: - electrum_hostname = elem.labelbase.user.profile.electrum_hostname or "fulcrum.sethforprivacy.com" - electrum_ports = elem.labelbase.user.profile.electrum_ports or "s50002" - elif elem.labelbase.is_testnet: - electrum_hostname = elem.labelbase.user.profile.electrum_hostname_test or "testnet.qtornado.com" - electrum_ports = elem.labelbase.user.profile.electrum_ports_test or "s51002" - else: - raise ValueError("Unknown network type.") - - server_info = ServerInfo(electrum_hostname, electrum_hostname, ports=(electrum_ports)) + print("Creating OutputStat") + output = OutputStat(user=elem.labelbase.user, + type_ref_hash=elem.type_ref_hash, + network=elem.labelbase.network, value=0) + print("Using OutputStat id {}".format(output)) + print("elem.type {} {} {} {}".format(elem.type, is_valid_output_ref(elem.ref), elem.ref, output.spent)) + if elem.type == "output" and is_valid_output_ref(elem.ref) and \ + (output.spent is not True or output.confirmed_at_block_time == 0): + electrum_hostname = elem.labelbase.user.profile.electrum_hostname + if not electrum_hostname: + electrum_hostname = "electrum.emzy.de" + electrum_ports = elem.labelbase.user.profile.electrum_ports + if not electrum_ports: + electrum_ports = "s50002" + print("going for server_info") + server_info = ServerInfo(electrum_hostname, electrum_hostname, ports=((electrum_ports))) + print("server_info: {}".format(server_info)) conn = StratumClient() + assert elem.type_ref_hash utxo = elem.ref - - # Fetch transaction details + tx_hash, tx_pos = elem.ref.split(":") utxo_resp = loop.run_until_complete(interact(conn, server_info, "blockchain.transaction.get", utxo)) - + blocktime = 0 if utxo_resp: - txid, index, address, value, blocktime, utxo_data = utxo_resp - logger.debug(f"Transaction {txid} fetched with blocktime {blocktime}") + print("utxo_resp {}".format(utxo_resp)) + txid, index, address, value, blocktime = utxo_resp if blocktime: - output.confirmed_at_block_time = blocktime - HistoricalPrice.get_or_create_from_api(None, timestamp=blocktime) - - # Fetch all unspents for the address + print("Found blocktime {} for label id {}.".format(blocktime, label_id)) + HistoricalPrice.get_or_create_from_api(timestamp=blocktime) try: unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.address.listunspent", address)) except: - conn.last_error = None + conn.last_error = None # reset error if needed unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.scripthash.listunspent", address)) - logger.debug(f"Unspents for address {address}: {unspents}") + unspent_utxo = False + utxo_value = 0 + utxo_height = 0 + print("unspents: {}".format(unspents)) - utxo_found = False - for unspent in unspents: - if unspent.get('tx_hash') == txid and unspent.get('tx_pos') == int(index): + if unspents: + for unspent in unspents: + if unspent.get('tx_hash') == tx_hash and \ + unspent.get('tx_pos') == int(tx_pos) and \ + unspent.get('height') > 0 and \ + unspent.get('value') > 0: + unspent_utxo = True + utxo_value = unspent.get('value') + utxo_height = unspent.get('height') + print("found unspent: {}".format(unspent)) + break + if output: + output.network = elem.labelbase.network + if utxo_height: + output.confirmed_at_block_height = utxo_height + if blocktime: + output.confirmed_at_block_time = blocktime + if utxo_value: + output.value = utxo_value + elif value: # take value from TX + output.value = value + if unspent_utxo: output.spent = False - output.value = unspent.get('value', None) - output.confirmed_at_block_height = unspent.get('height', None) - utxo_found = True - - # Ensure all key details are stored - output.network = elem.labelbase.network - if unspent.get('height'): - output.confirmed_at_block_height = unspent.get('height') - if blocktime: - output.confirmed_at_block_time = blocktime - if unspent.get('value') is not None: - output.value = unspent.get('value') - elif value: - output.value = value - break - - if not utxo_found: - output.spent = True - logger.warning(f"UTXO {txid}:{index} not found in unspent outputs.") - + else: + output.spent = True + output.last_error = {} elif conn.last_error: + # Damn... output.last_error = conn.last_error else: - logger.warning(f"Unknown error occurred for UTXO {utxo}") - output.last_error = {"error": "Unknown issue"} - - logger.debug(f"Output after processing (before save): {output.output_metrics_dict()}") + output.last_error = {} output.save() - output.refresh_from_db() - logger.debug(f"Output after saving: {output.output_metrics_dict()}") - - except Exception as e: - logger.error(f"Error processing label {label_id}: {e}") + print("output id {} saved".format(output.id)) + try: + conn.close() + except: + pass + else: + if not label_id: + logger.error("Can't get label_id! {}".format(label_id)) + if not loop: + logger.error("Can't get loop!") diff --git a/django/finances/migrations/0012_auto_20240701_0932.py b/django/finances/migrations/0012_auto_20240701_0932.py deleted file mode 100644 index f357d8c..0000000 --- a/django/finances/migrations/0012_auto_20240701_0932.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 3.2.25 on 2024-07-01 09:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('finances', '0011_alter_outputstat_network'), - ] - - operations = [ - migrations.RemoveField( - model_name='outputstat', - name='next_input_attributes', - ), - migrations.AddField( - model_name='outputstat', - name='next_enc_input_attrs', - field=models.TextField(default=None, null=True), - ), - ] diff --git a/django/finances/models.py b/django/finances/models.py index b123cb2..9928aea 100644 --- a/django/finances/models.py +++ b/django/finances/models.py @@ -1,6 +1,5 @@ import requests from django.db import models -from django.contrib import messages from djmoney.models.fields import MoneyField from decimal import Decimal import datetime @@ -8,13 +7,14 @@ from pymempool import MempoolAPI from labelbase.receivers import compute_type_ref_hash from django.conf import settings from jsonfield import JSONField -import json from django.contrib.auth.models import User -from shared.encryption import get_fernet_key, cipher_suite + + import logging logger = logging.getLogger('labelbase') + class OutputStat(models.Model): """ These fields are unencrypted. Why? @@ -43,20 +43,7 @@ class OutputStat(models.Model): confirmed_at_block_time = models.IntegerField(default=0) last_error = JSONField(default={}) - next_enc_input_attrs = models.TextField(default=None, null=True) # will be used for fee estimation - - def set_next_input_attributes(self, data): - json_data = json.dumps(data).encode('utf-8') - encrypted_data = cipher_suite.encrypt(json_data) - self.next_enc_input_attrs = encrypted_data.decode('utf-8') - - def next_input_attributes(self): - if self.next_enc_input_attrs: - encrypted_data = self.next_enc_input_attrs.encode('utf-8') - decrypted_data = cipher_suite.decrypt(encrypted_data) - return json.loads(decrypted_data.decode('utf-8')) - return json.loads("{}") - + next_input_attributes = JSONField(default={}) # will be used for fee estimation MAINNET = 'mainnet' TESTNET = 'testnet' @@ -77,12 +64,12 @@ class OutputStat(models.Model): @property def get_spent_status(self): + if self.confirmed_at_block_time == 0: + return "unconfirmed" if self.spent: return "spent" - elif not self.spent: + if not self.spent: return "unspent" - elif self.confirmed_at_block_time == 0: - return "unconfirmed" def output_metrics_dict(self, tracked_fiat_value=0, fiat_currency="USD"): """ @@ -102,8 +89,8 @@ class OutputStat(models.Model): # Check if the block time is confirmed if self.confirmed_at_block_time: # Get or create HistoricalPrice instance for the confirmed block time - obj, created = HistoricalPrice.get_or_create_from_api(self.user, - timestamp=self.confirmed_at_block_time + obj, created = HistoricalPrice.get_or_create_from_api( + timestamp=self.confirmed_at_block_time ) if obj is None: logger.error("No price info found for {}".format(self.confirmed_at_block_time)) @@ -126,8 +113,8 @@ class OutputStat(models.Model): timestamp = int(current_datetime.timestamp()) # Get or create HistoricalPrice instance for the current time in UTC - obj_now, created = HistoricalPrice.get_or_create_from_api(self.user, - timestamp=timestamp + obj_now, created = HistoricalPrice.get_or_create_from_api( + timestamp=timestamp ) # Calculate the current price @@ -158,7 +145,7 @@ class OutputStat(models.Model): Parses 'tracked_fiat_value' and 'fiat_currency' information from the given label. """ if self.confirmed_at_block_time: - obj, created = HistoricalPrice.get_or_create_from_api(self.user, + obj, created = HistoricalPrice.get_or_create_from_api( timestamp=self.confirmed_at_block_time) performance = 0 @@ -206,11 +193,13 @@ class OutputStat(models.Model): network=network).last() if cached_data: + print("found cached data {} for type_ref_hash {}".format(cached_data, type_ref_hash)) return cached_data, False def get_value_and_spent(txid, vout): mempool_api = MempoolAPI() res0 = mempool_api.get_transaction(txid) + print("res0 {}".format(res0)) vouts = res0.get("vout", []) if vouts: value = vouts[int(vout)].get("value", 0) @@ -222,9 +211,10 @@ class OutputStat(models.Model): if txid and vout: res = get_value_and_spent(txid, vout) - + print (res) if res: value, spent, confirmed_at_block_height, confirmed_at_block_time = res + print("called data {} {} for type_ref_hash {}".format(value, spent, type_ref_hash)) obj, created = cls.objects.get_or_create(user=user, type_ref_hash=type_ref_hash, network=network, defaults={ @@ -276,31 +266,17 @@ class HistoricalPrice(models.Model): ordering = ['-timestamp'] @classmethod - def get_or_create_from_api(cls, user=None, timestamp=-1): + def get_or_create_from_api(cls, timestamp=-1): + print("running get_or_create_from_api @ timestamp {}".format(timestamp)) if timestamp == -1: current_datetime = datetime.datetime.now() timestamp = int(current_datetime.timestamp()) cached_data = cls.objects.filter(timestamp=timestamp).first() if cached_data: return cached_data, False - try: - if user: - mempool_endpoint = user.profile.mempool_endpoint - else: - mempool_endpoint = "https://mempool.space" - url = f"{mempool_endpoint}/api/v1/historical-price?timestamp={timestamp}" - response = requests.get(url) - api_response = response.json() - except Exception as ex: - logger.error(ex, exc_info=True) - try: - from threadlocals.threadlocals import get_current_request - request = get_current_request() - if request: - messages.error(request, "Connection Error: Could not connect to Mempool to retrieve historical price.") - except Exception as ex2: - logger.error(ex2, exc_info=True) - return None, None + url = f"https://mempool.space/api/v1/historical-price?timestamp={timestamp}" + response = requests.get(url) + api_response = response.json() try: obj, created = cls.objects.get_or_create(timestamp=timestamp, defaults={ 'usd_price': Decimal(str(api_response['prices'][0]['USD'])), diff --git a/django/finances/signals.py b/django/finances/signals.py index df9078e..40fdbe6 100644 --- a/django/finances/signals.py +++ b/django/finances/signals.py @@ -1,25 +1,20 @@ from django.contrib.auth.signals import user_logged_in -from django.contrib import messages from django.dispatch import receiver from finances.models import HistoricalPrice +from django.contrib import messages + + + + @receiver(user_logged_in) def perform_tasks_on_login(sender, user, request, **kwargs): - try: - if user.profile.update_utxo_on_login: - from finances.tasks import check_all_outputs - from labelbase.models import Label - check_all_outputs(user.id) - if Label.objects.filter(labelbase__user_id=user.id).exists(): - messages.info(request, ( - "Sync in progress: " - "We are checking your unspent transaction outputs now." - )) - except Exception as ex: - messages.info(request, ( - "Oups: " - f"{ex}" - )) - + """ """ + if user.profile.update_utxo_on_login: + from finances.tasks import check_all_outputs + from labelbase.models import Label + check_all_outputs(user.id) + if Label.objects.filter(labelbase__user_id=user.id).exists(): + messages.info(request, "Sync in progress: We are checking your unspent transaction outputs now.") # Store nearest price information. - HistoricalPrice.get_or_create_from_api(user, -1) + HistoricalPrice.get_or_create_from_api(-1) diff --git a/django/finances/tasks.py b/django/finances/tasks.py index 7816cbb..fb43e22 100644 --- a/django/finances/tasks.py +++ b/django/finances/tasks.py @@ -1,6 +1,8 @@ import logging from background_task import background from background_task.management.commands.remove_completed import _remove_completed_task + + from labelbase.models import Label from finances.electrum import checkup_label diff --git a/django/finances/tx_math.py b/django/finances/tx_math.py deleted file mode 100644 index 18940d5..0000000 --- a/django/finances/tx_math.py +++ /dev/null @@ -1,223 +0,0 @@ -# Constants -P2PKH_IN_SIZE = 148 -P2PKH_OUT_SIZE = 34 - -P2SH_OUT_SIZE = 32 -P2SH_P2WPKH_OUT_SIZE = 32 -P2SH_P2WSH_OUT_SIZE = 32 - -P2WPKH_OUT_SIZE = 31 -P2WSH_OUT_SIZE = P2TR_OUT_SIZE = 43 - -PUBKEY_SIZE = 33 -SIGNATURE_SIZE = 72 - - -def get_size_of_var_int(length): - if length <= 252: - return 1 - elif length <= 0xffff: - return 3 - elif length <= 0xffffffff: - return 5 - else: - return 9 - - -def get_size_of_script_length_element(length): - if length < 75: - return 1 - elif length <= 255: - return 2 - elif length <= 65535: - return 3 - elif length <= 4294967295: - return 5 - else: - raise ValueError("Script too large") - - -def get_input_size(input_attr): - """Return (base_size, witness_size) for a single input.""" - script_type = input_attr['input_script'] - m = input_attr.get('input_m', 0) - n = input_attr.get('input_n', 0) - - if script_type == "P2PKH": - return P2PKH_IN_SIZE, 0 - elif script_type == "P2SH": - # Redeem script: OP_m + n_pubkeys + OP_n + OP_CHECKMULTISIG - redeem_script_size = 1 + n * (1 + PUBKEY_SIZE) + 1 + 1 - # scriptSig: OP_0 + m_sigs + push_opcode + redeem_script - script_sig_size = 1 + m * (1 + SIGNATURE_SIZE) + get_size_of_script_length_element(redeem_script_size) + redeem_script_size - input_base_size = 32 + 4 + get_size_of_var_int(script_sig_size) + script_sig_size + 4 - return input_base_size, 0 - elif script_type == "P2SH-P2WPKH": - input_base_size = 32 + 4 + 1 + 23 + 4 - input_witness_size = 107 - return input_base_size, input_witness_size - elif script_type == "P2WPKH": - input_base_size = 32 + 4 + 1 + 4 - input_witness_size = 107 - return input_base_size, input_witness_size - elif script_type == "P2WSH": - input_base_size = 32 + 4 + 1 + 4 - witness_script_size = 1 + n * (1 + PUBKEY_SIZE) + 1 + 1 - num_stack_items = 1 + m + 1 # OP_0 + m sigs + witness script - input_witness_size = ( - get_size_of_var_int(num_stack_items) + - 1 + # OP_0 length - m * (1 + SIGNATURE_SIZE) + - get_size_of_var_int(witness_script_size) + - witness_script_size - ) - return input_base_size, input_witness_size - elif script_type == "P2TR": - input_base_size = 32 + 4 + 1 + 4 - input_witness_size = 65 - return input_base_size, input_witness_size - else: - raise ValueError(f"Unsupported input script type: {script_type}") - - -def calculate_transaction_size(inputs, output_counts): - """ - inputs: list of dicts with keys: input_script, input_m, input_n - output_counts: dict with counts per output type - """ - total_base = 0 - total_witness = 0 - - - # Total inputs / outputs - input_count = len(inputs) - output_count = sum(output_counts.values()) - - # Transaction overhead: version(4) + varints + locktime(4) - tx_base_size = 4 + get_size_of_var_int(input_count) + get_size_of_var_int(output_count) + 4 - - - # Segwit marker + flag if any input is segwit - has_witness = any(inp['input_script'] in ["P2SH-P2WPKH", "P2WPKH", "P2WSH", "P2TR"] for inp in inputs) - if has_witness: - total_witness += 2 # marker + flag - - for inp in inputs: - base, witness = get_input_size(inp) - total_base += base - total_witness += witness - - # Sum output sizes - output_size = (P2PKH_OUT_SIZE * output_counts.get('p2pkh', 0) + - P2SH_OUT_SIZE * output_counts.get('p2sh', 0) + - P2SH_P2WPKH_OUT_SIZE * output_counts.get('p2sh_p2wpkh', 0) + - P2SH_P2WSH_OUT_SIZE * output_counts.get('p2sh_p2wsh', 0) + - P2WPKH_OUT_SIZE * output_counts.get('p2wpkh', 0) + - P2WSH_OUT_SIZE * output_counts.get('p2wsh', 0) + - P2TR_OUT_SIZE * output_counts.get('p2tr', 0)) - - - # Total base size - tx_total_base_size = tx_base_size + total_base + output_size - - # Transaction weight and vbytes - tx_weight = tx_total_base_size * 4 + total_witness - tx_vbytes = tx_weight / 4 - - # Raw bytes (base + witness discounted by 1/4) - tx_bytes = tx_total_base_size + total_witness / 4 - - return { - 'txBytes': round(tx_bytes), - 'txVBytes': round(tx_vbytes), - 'txWeight': tx_weight - } - - - -def calculate_fee(tx_vbytes, fee_rate_sats_per_vbyte): - """ - tx_vbytes: virtual size from calculate_transaction_size() - fee_rate_sats_per_vbyte: fee rate in sats per vbyte - """ - return round(tx_vbytes * fee_rate_sats_per_vbyte) - - - -def run_tests(): - fee_rate = 20 # sats per vbyte - test_cases = [ - # 1. Single P2PKH input -> single P2PKH output - { - 'inputs': [{'input_script': 'P2PKH'}], - 'outputs': {'p2pkh': 1}, - 'description': 'Single P2PKH -> P2PKH' - }, - # 2. Two P2WPKH inputs -> two P2WPKH outputs - { - 'inputs': [{'input_script': 'P2WPKH'}, {'input_script': 'P2WPKH'}], - 'outputs': {'p2wpkh': 2}, - 'description': 'Two P2WPKH -> Two P2WPKH' - }, - # 3. Single P2SH 2-of-3 multisig input -> two P2PKH outputs - { - 'inputs': [{'input_script': 'P2SH', 'input_m': 2, 'input_n': 3}], - 'outputs': {'p2pkh': 2}, - 'description': 'P2SH 2-of-3 multisig -> 2x P2PKH' - }, - # 4. Mixed inputs: P2PKH + P2WPKH + P2TR -> P2PKH + P2WPKH - { - 'inputs': [ - {'input_script': 'P2PKH'}, - {'input_script': 'P2WPKH'}, - {'input_script': 'P2TR'} - ], - 'outputs': {'p2pkh': 1, 'p2wpkh': 1}, - 'description': 'Mixed inputs -> mixed outputs' - }, - # 5. Two P2WSH multisig inputs -> P2WSH outputs - { - 'inputs': [ - {'input_script': 'P2WSH', 'input_m': 2, 'input_n': 3}, - {'input_script': 'P2WSH', 'input_m': 1, 'input_n': 2} - ], - 'outputs': {'p2wsh': 2}, - 'description': 'Two P2WSH -> Two P2WSH' - } - ] - - for idx, test in enumerate(test_cases, 1): - # Step 1: calculate size - tx_size = calculate_transaction_size(test['inputs'], test['outputs']) - - # Step 2: calculate fee - fee_sats = calculate_fee(tx_size['txVBytes'], fee_rate) - - print(f"Test {idx}: {test['description']}") - print(f" txBytes: {tx_size['txBytes']}, txVBytes: {tx_size['txVBytes']}, txWeight: {tx_size['txWeight']}") - print(f" Fee (@ {fee_rate} sats/vbyte): {fee_sats} sats\n") - - - -# Example usage -if __name__ == "__main__": - inputs = [ - {'input_script': 'P2PKH'}, - {'input_script': 'P2WPKH'}, - {'input_script': 'P2SH', 'input_m': 2, 'input_n': 3}, - ] - output_counts = { - 'p2pkh': 1, - 'p2sh': 0, - 'p2sh_p2wpkh': 1, - 'p2sh_p2wsh': 0, - 'p2wpkh': 0, - 'p2wsh': 0, - 'p2tr': 0 - } - - tx_size = calculate_transaction_size(inputs, output_counts) - print(tx_size) - - print("*"*80) - run_tests() diff --git a/django/finances/tx_math_tests.py b/django/finances/tx_math_tests.py deleted file mode 100644 index 4ad610e..0000000 --- a/django/finances/tx_math_tests.py +++ /dev/null @@ -1,48 +0,0 @@ -def run_tests(): - test_cases = [ - # 1. Single P2PKH input -> single P2PKH output - { - 'inputs': [{'input_script': 'P2PKH'}], - 'outputs': {'p2pkh': 1}, - 'description': 'Single P2PKH -> P2PKH' - }, - # 2. Two P2WPKH inputs -> two P2WPKH outputs - { - 'inputs': [{'input_script': 'P2WPKH'}, {'input_script': 'P2WPKH'}], - 'outputs': {'p2wpkh': 2}, - 'description': 'Two P2WPKH -> Two P2WPKH' - }, - # 3. Single P2SH 2-of-3 multisig input -> two P2PKH outputs - { - 'inputs': [{'input_script': 'P2SH', 'input_m': 2, 'input_n': 3}], - 'outputs': {'p2pkh': 2}, - 'description': 'P2SH 2-of-3 multisig -> 2x P2PKH' - }, - # 4. Mixed inputs: P2PKH + P2WPKH + P2TR -> P2PKH + P2WPKH - { - 'inputs': [ - {'input_script': 'P2PKH'}, - {'input_script': 'P2WPKH'}, - {'input_script': 'P2TR'} - ], - 'outputs': {'p2pkh': 1, 'p2wpkh': 1}, - 'description': 'Mixed inputs -> mixed outputs' - }, - # 5. Two P2WSH multisig inputs -> P2WSH outputs - { - 'inputs': [ - {'input_script': 'P2WSH', 'input_m': 2, 'input_n': 3}, - {'input_script': 'P2WSH', 'input_m': 1, 'input_n': 2} - ], - 'outputs': {'p2wsh': 2}, - 'description': 'Two P2WSH -> Two P2WSH' - } - ] - - for idx, test in enumerate(test_cases, 1): - result = calculate_transaction_size(test['inputs'], test['outputs']) - print(f"Test {idx}: {test['description']}") - print(f" txBytes: {result['txBytes']}, txVBytes: {result['txVBytes']}, txWeight: {result['txWeight']}\n") - -if __name__ == "__main__": - run_tests() diff --git a/django/importer/forms.py b/django/importer/forms.py index e14fe52..d3ae1ca 100644 --- a/django/importer/forms.py +++ b/django/importer/forms.py @@ -8,8 +8,3 @@ class UploadFileForm(forms.Form): choices=IMPORTER_CHOICES ) file = forms.FileField() - passphrase = forms.CharField( - widget=forms.PasswordInput(), - required=False, - max_length=100 - ) diff --git a/django/importer/models.py b/django/importer/models.py index 11c8f31..82cafed 100644 --- a/django/importer/models.py +++ b/django/importer/models.py @@ -2,18 +2,15 @@ from django.db import models from django.contrib.auth.models import User from labelbase.models import Labelbase from uuid_upload_path import upload_to -from django.conf import settings -IMPORTER_CHOICES = [ +IMPORTER_CHOICES = ( ("BIP-0329", "BIP-329 .jsonl"), # TODO: ("BIP-0329-7z-enc" , "BIP-329 (encrypted) .7z"), ("csv-bluewallet", "BlueWallet .csv"), ("csv-bitbox", "BitBox .csv"), - ("pocket-accointing", "Pocket Accointing .csv"), -] + ("pocket-accointing", "Pocket Accointing .csv") +) -if settings.SELF_HOSTED: - IMPORTER_CHOICES.append(("samourai", "Samourai .txt, (v2)")) class UploadedData(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) diff --git a/django/importer/pocket.py b/django/importer/pocket.py index 9d8af30..bbc88ca 100644 --- a/django/importer/pocket.py +++ b/django/importer/pocket.py @@ -1,5 +1,7 @@ import csv -import json +import json +from labelbase.models import Label +from labelbase.models import Labelbase def validate_csv_format(csv_file_path): diff --git a/django/importer/samourai.py b/django/importer/samourai.py deleted file mode 100644 index 91fb3b1..0000000 --- a/django/importer/samourai.py +++ /dev/null @@ -1,151 +0,0 @@ -import json -from Crypto.Cipher import AES -from Crypto.Hash import SHA256 -from Crypto.Protocol.KDF import PBKDF2 -import hashlib -import re -import base64 - -from labelbase.serializers import LabelSerializer -from labelbase.models import Label - -import logging -logger = logging.getLogger('labelbase') - -DefaultPBKDF2Iterations = 5000 -DefaultPBKDF2HMACSHA256Iterations = 15000 -DefaultSamouraiImportLabel = "Imported form samourai.txt" - - - - - -def decrypt_v1(payload, password, iterations=DefaultPBKDF2Iterations): - # V1 uses PBKDF2 for key derivation and AES for decryption - AESBlockSize = 16 - cipherdata = base64.b64decode(payload) - iv = cipherdata[:AESBlockSize] - input_data = cipherdata[AESBlockSize:] - key = PBKDF2(password, iv, dkLen=32, count=iterations) - cipher = AES.new(key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(input_data) - return decrypted.rstrip(b"\x00").decode('utf-8') - - -def decrypt_v2(payload, password, iterations=DefaultPBKDF2HMACSHA256Iterations): - # V2 uses SHA256 for key derivation and AES for decryption - encrypted_bytes = base64.b64decode(payload.replace("\n", "")) - salt = encrypted_bytes[8:16] - cipher_text = encrypted_bytes[16:] - key_iv = PBKDF2(password, salt, dkLen=48, count=iterations, hmac_hash_module=SHA256) - key = key_iv[:32] - iv = key_iv[32:] - cipher = AES.new(key, AES.MODE_CBC, iv) - decrypted = cipher.decrypt(cipher_text) - pad_len = decrypted[-1] - decrypted = decrypted[:-pad_len] - return decrypted.decode('utf-8') - - -def import_samourai_labels(labelbase, content, passphrase): - content = content.decode('utf-8') - pattern = re.compile(r'\{.*?\}') - match = pattern.search(content) - imported_lables = 0 - payload = None - if match: - json_content = match.group(0) - try: - logger.info(f"json_content {json_content}") - data = json.loads(json_content) - logger.info(f"data: {data}") - version = data.get("version", 1) - payload = data.get("payload", "") - if payload: - if version in [1, "1"]: - decrypted_data = decrypt_v1(payload, passphrase) - elif version in [2, "2"]: - decrypted_data = decrypt_v2(payload, passphrase) - else: - logger.error(f"Unsupported backup version: {version}") - raise ValueError(f"Unsupported backup version: {version}") - logger.info(decrypted_data) - samourai_data = json.loads(decrypted_data) - logger.info(samourai_data) - - """ - DOC/KB: If the labelbase where you import your samourai.txt into, labelbase will set the fingerprint, - """ - labels = Label.objects.filter(labelbase__id=labelbase.id) - if labels.count() == 0: - if not labelbase.fingerprint: - labelbase.fingerprint = samourai_data.get('wallet').get('fingerprint') - if samourai_data.get('wallet').get('testnet'): - labelbase.network == labelbase.TESTNET - else: - labelbase.network == labelbase.MAINNET - labelbase.save() - - xpub = samourai_data.get('wallet', {}).get('accounts')[0].get('xpub') - ypub = samourai_data.get('wallet', {}).get('bip49_accounts')[0].get('ypub') - zpub = samourai_data.get('wallet', {}).get('bip84_accounts')[0].get('zpub') - - for pub in [xpub, ypub, zpub]: - if pub: - _data = { - "type": Label.TYPE_XPUB, - "ref": pub, - "label": DefaultSamouraiImportLabel, - } - _data["labelbase"] = labelbase.id - serializer = LabelSerializer(data=_data) - if serializer.is_valid(): - serializer.save() - imported_lables += 1 - - utxo_notes = samourai_data.get('meta', {}).get('utxo_notes') - logger.info(utxo_notes) - for note in utxo_notes: - _data = { - "type": Label.TYPE_TX, - "ref": note[0], - "label": note[1], - } - _data["labelbase"] = labelbase.id - serializer = LabelSerializer(data=_data) - if serializer.is_valid(): - serializer.save() - imported_lables += 1 - blocked_utxos = samourai_data.get('meta', {}).get('blocked_utxos',{}).get('blocked') - logger.info(blocked_utxos) - for utxo in blocked_utxos: - _data = { - "type": Label.TYPE_OUTPUT, - "ref": utxo.get('id','').replace("-", ":"), - "label": DefaultSamouraiImportLabel, - "spendable": False - } - _data["labelbase"] = labelbase.id - serializer = LabelSerializer(data=_data) - if serializer.is_valid(): - serializer.save() - imported_lables += 1 - return imported_lables - - else: - print("No payload found in the JSON content.") - logger.error("No payload found in the JSON content.") - return imported_lables - except json.JSONDecodeError as e: - print(f"JSONDecodeError: {e}") - logger.error(f"JSONDecodeError: {e}") - return imported_lables - except Exception as ex: - print(f"An error occurred: {ex}") - logger.error(f"An error occurred: {ex}") - logger.error(ex, exc_info=True) - return imported_lables - else: - print("No JSON found in file.") - logger.error("No JSON found in file.") - return imported_lables diff --git a/django/importer/tasks.py b/django/importer/tasks.py index d147798..a29b72b 100644 --- a/django/importer/tasks.py +++ b/django/importer/tasks.py @@ -2,10 +2,9 @@ from background_task import background import json import decimal +from labelbase.models import Labelbase from labelbase.serializers import LabelSerializer -import logging -logger = logging.getLogger('labelbase') from .models import UploadedData @@ -13,135 +12,93 @@ EOLSTOP = [b"", "", None, "\n"] @background(schedule=1) -def process_uploaded_data(uploaded_data_id, passphrase=None, loop=None): - try: - uploaded_data = UploadedData.objects.get(pk=uploaded_data_id) - imported_lables = 0 - labelbase = uploaded_data.labelbase - fp = uploaded_data.file.open() +def process_uploaded_data(uploaded_data_id, loop=None): + imported_lables = 0 + uploaded_data = UploadedData.objects.get(pk=uploaded_data_id) + labelbase = uploaded_data.labelbase + fp = uploaded_data.file.open() - # BIP-0329 - if uploaded_data.import_type == "BIP-0329": - while True: - buf = fp.readline() - if buf in EOLSTOP: - break - data = json.loads(buf) - - logger.info(f"Parsed data: {data}") - - data["labelbase"] = labelbase.id - serializer = LabelSerializer(data=data) - if serializer.is_valid(): - serializer.save() - imported_lables += 1 - elif uploaded_data.import_type == "BIP-0329-7z-enc": - # TODO: Implementation needed. - pass - elif uploaded_data.import_type == "samourai": - buf = fp.read() - logger.info(buf) - print(buf) - from .samourai import import_samourai_labels - import_samourai_labels(labelbase, buf, passphrase) - # Bitbox App - elif uploaded_data.import_type == "csv-bitbox": - while True: - buf = fp.readline() - if buf in EOLSTOP: - break - try: - buf = str(buf.decode("utf-8")) - sbuf = buf.split(",") - # Time,Type,Amount,Unit,Fee,Address,Transaction ID,Note - for elem in [("tx", 6), ("addr", 5)]: - data = { - "type": elem[0], - "ref": sbuf[elem[1]], - "label": " ".join(sbuf[7:]), - } - data["labelbase"] = labelbase.id - serializer = LabelSerializer(data=data) - if serializer.is_valid(): - serializer.save() - imported_lables += 1 - else: - messages.add_message( - request, - messages.ERROR, - 'Could not process line "{}".'.format(buf), - ) - except Exception as ex: - messages.add_message( - request, - messages.ERROR, - 'Could not process line "{}", {}.'.format(buf, ex), - ) - # Pocket Accointing - elif uploaded_data.import_type == "pocket-accointing": - fp.close() - csv_file_path = fp.name - mempool_api = labelbase.get_mempool_api() - from .pocket import validate_csv_format, parse_csv_to_json - if validate_csv_format(csv_file_path): - for item in parse_csv_to_json(csv_file_path): - label = "Got {} {} for {:.2f} {} with reference: {} #Pocket".format(item[0].get('outSellAmount'), item[1].get( - 'inBuyAsset'), decimal.Decimal(item[2].get('inBuyAmount')), item[2].get('inBuyAsset'), item[1].get('operationId')) - txid = item[0].get('operationId') - tx = mempool_api.get_transaction(txid) - potential_utxos = [] - vouts = tx.get("vout", []) - for i in range(len(vouts)): - if vouts[i].get('value', 0) == decimal.Decimal(item[0].get('outSellAmount'))*100000000: - potential_utxos.append("{}:{}".format(txid, i )) - data = {} - if len(potential_utxos) == 1: - # label UTXO/output of tx - data = { - "type": "output", - "ref": potential_utxos[0], - "label": label, - } - if len(potential_utxos) > 1: - # mark tx, add warning tag - data = { - "type": "tx", - "ref": txid, - "label": "{} {}".format(label, "#W001_UTXO_NOT_FOUND"), - } - if data: - data["labelbase"] = labelbase.id - serializer = LabelSerializer(data=data) - if serializer.is_valid(): - serializer.save() - imported_lables += 1 - else: - # messages.add_message( - # request, - # messages.ERROR, - # 'Could not process record "{}".'.format(item), - # ) - pass - else: - print("ERROR") # TODO - # BlueWallet - elif uploaded_data.import_type == "csv-bluewallet": - header_row = True - while True: - buf = fp.readline() - if buf in EOLSTOP: - break - if header_row: - header_row = False - continue - try: - buf = str(buf.decode("utf-8")) - sbuf = buf.split(",") + # BIP-0329 + if uploaded_data.import_type == "BIP-0329": + while True: + buf = fp.readline() + if buf in EOLSTOP: + break + data = json.loads(buf) + data["labelbase"] = labelbase.id + serializer = LabelSerializer(data=data) + if serializer.is_valid(): + serializer.save() + imported_lables += 1 + elif uploaded_data.import_type == "BIP-0329-7z-enc": + # TODO: Implementation needed. + pass + # Bitbox App + elif uploaded_data.import_type == "csv-bitbox": + while True: + buf = fp.readline() + if buf in EOLSTOP: + break + try: + buf = str(buf.decode("utf-8")) + sbuf = buf.split(",") + # Time,Type,Amount,Unit,Fee,Address,Transaction ID,Note + for elem in [("tx", 6), ("addr", 5)]: + data = { + "type": elem[0], + "ref": sbuf[elem[1]], + "label": " ".join(sbuf[7:]), + } + data["labelbase"] = labelbase.id + serializer = LabelSerializer(data=data) + if serializer.is_valid(): + serializer.save() + imported_lables += 1 + else: + messages.add_message( + request, + messages.ERROR, + 'Could not process line "{}".'.format(buf), + ) + except Exception as ex: + messages.add_message( + request, + messages.ERROR, + 'Could not process line "{}", {}.'.format(buf, ex), + ) + # Pocket Accointing + elif uploaded_data.import_type == "pocket-accointing": + fp.close() + csv_file_path = fp.name + mempool_api = labelbase.get_mempool_api() + from .pocket import validate_csv_format, parse_csv_to_json + if validate_csv_format(csv_file_path): + for item in parse_csv_to_json(csv_file_path): + label = "Got {} {} for {:.2f} {} with reference: {} #Pocket".format(item[0].get('outSellAmount'), item[1].get( + 'inBuyAsset'), decimal.Decimal(item[2].get('inBuyAmount')), item[2].get('inBuyAsset'), item[1].get('operationId')) + txid = item[0].get('operationId') + tx = mempool_api.get_transaction(txid) + potential_utxos = [] + vouts = tx.get("vout", []) + for i in range(len(vouts)): + if vouts[i].get('value', 0) == decimal.Decimal(item[0].get('outSellAmount'))*100000000: + potential_utxos.append("{}:{}".format(txid, i )) + data = {} + if len(potential_utxos) == 1: + # label UTXO/output of tx + data = { + "type": "output", + "ref": potential_utxos[0], + "label": label, + } + if len(potential_utxos) > 1: + # mark tx, add warning tag data = { "type": "tx", - "ref": sbuf[1], - "label": " ".join(sbuf[3:]), + "ref": txid, + "label": "{} {}".format(label, "#W001_UTXO_NOT_FOUND"), } + if data: data["labelbase"] = labelbase.id serializer = LabelSerializer(data=data) if serializer.is_valid(): @@ -151,20 +108,50 @@ def process_uploaded_data(uploaded_data_id, passphrase=None, loop=None): # messages.add_message( # request, # messages.ERROR, - # 'Could not process line "{}".'.format(buf), + # 'Could not process record "{}".'.format(item), # ) pass - except Exception as ex: + else: + print("ERROR") # TODO + # BlueWallet + elif uploaded_data.import_type == "csv-bluewallet": + header_row = True + while True: + buf = fp.readline() + if buf in EOLSTOP: + break + if header_row: + header_row = False + continue + try: + buf = str(buf.decode("utf-8")) + sbuf = buf.split(",") + data = { + "type": "tx", + "ref": sbuf[1], + "label": " ".join(sbuf[3:]), + } + data["labelbase"] = labelbase.id + serializer = LabelSerializer(data=data) + if serializer.is_valid(): + serializer.save() + imported_lables += 1 + else: # messages.add_message( # request, # messages.ERROR, - # 'Could not process line "{}", {}.'.format(buf, ex), + # 'Could not process line "{}".'.format(buf), # ) pass - # Clean up – Note: Currently we delete the upload from the file system, - # later we can store the messages.add_message messages, the state and the - # amount of importet labels in it to propagate the messages to the - # frontend/user interface. - uploaded_data.delete() - except Exception as ex: - logger.error(ex, exc_info=True) + except Exception as ex: + # messages.add_message( + # request, + # messages.ERROR, + # 'Could not process line "{}", {}.'.format(buf, ex), + # ) + pass + # Clean up – Note: Currently we delete the upload from the file system, + # later we can store the messages.add_message messages, the state and the + # amount of importet labels in it to propagate the messages to the + # frontend/user interface. + uploaded_data.delete() diff --git a/django/importer/templatetags/importer_tags.py b/django/importer/templatetags/importer_tags.py index a10cfb0..dc43461 100644 --- a/django/importer/templatetags/importer_tags.py +++ b/django/importer/templatetags/importer_tags.py @@ -10,4 +10,27 @@ def genericlabeluploadform(labelbase_id): form.fields["labelbase_id"].initial = labelbase_id form.fields["import_type"].initial = "BIP-0329" return form - + + +@register.simple_tag +def bip0329labeluploadform(labelbase_id): + form = UploadFileForm() + form.fields["labelbase_id"].initial = labelbase_id + form.fields["import_type"].initial = "BIP-0329" + return form + + +@register.simple_tag +def csvBlueWalletlabeluploadform(labelbase_id): + form = UploadFileForm() + form.fields["labelbase_id"].initial = labelbase_id + form.fields["import_type"].initial = "csv-bluewallet" + return form + + +@register.simple_tag +def csvBitBoxLabeluploadform(labelbase_id): + form = UploadFileForm() + form.fields["labelbase_id"].initial = labelbase_id + form.fields["import_type"].initial = "csv-bitbox" + return form diff --git a/django/importer/views.py b/django/importer/views.py index 7b72d57..62e1dd1 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, passphrase=form.cleaned_data.get("passphrase", None)) + process_uploaded_data(uploaded_data.id) messages.add_message( request, messages.INFO, diff --git a/django/labelbase/admin.py b/django/labelbase/admin.py index a24e712..358c694 100644 --- a/django/labelbase/admin.py +++ b/django/labelbase/admin.py @@ -1,36 +1,6 @@ from django.contrib import admin from .models import Labelbase from .models import Label -from django.conf import settings -class LabelbaseAdmin(admin.ModelAdmin): - list_display = ['id', 'user', 'name', 'network', 'operation_mode'] - list_filter = ['network', 'operation_mode'] - search_fields = ['name', 'fingerprint'] - - -class LabelAdmin(admin.ModelAdmin): - list_display = ['id', 'type', 'labelbase', 'label'] - list_filter = ['type', 'labelbase__network'] - search_fields = ['ref', 'label'] - - # Organize fields into logical sections - fieldsets = ( - ('Core BIP-329 Fields', { - 'fields': ('labelbase', 'type', 'ref', 'label', 'origin', 'spendable') - }), - ('Additional BIP-329 Fields', { - 'fields': ('height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights'), - 'classes': ('collapse',), # Make this section collapsible - }), - ('Internal', { - 'fields': ('type_ref_hash',), - 'classes': ('collapse',), - }), - ) - - readonly_fields = ['type_ref_hash'] - -if settings.DEBUG: - admin.site.register(Labelbase, LabelbaseAdmin) - admin.site.register(Label, LabelAdmin) +admin.site.register(Labelbase) +admin.site.register(Label) diff --git a/django/labelbase/api.py b/django/labelbase/api.py index 5781795..2ec8a2d 100644 --- a/django/labelbase/api.py +++ b/django/labelbase/api.py @@ -10,9 +10,6 @@ from labelbase.models import Labelbase, Label from labelbase.serializers import LabelbaseSerializer, LabelSerializer -import logging -logger = logging.getLogger('labelbase') - class LabelbaseAPIView(APIView): """ Labelbase @@ -93,7 +90,7 @@ class LabelAPIView(APIView): "spendable": request.data.get("spendable", "null"), } - #logger.debug(f"data: {data}") + serializer = LabelSerializer(data=data) if serializer.is_valid(): serializer.save() diff --git a/django/labelbase/forms.py b/django/labelbase/forms.py index d2fcec6..c7cf230 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 deleted file mode 100644 index c84a26b..0000000 --- a/django/labelbase/migrations/0012_auto_20251122_0800.py +++ /dev/null @@ -1,54 +0,0 @@ -# Generated by Django 3.2.25 on 2025-11-22 08:00 - -from django.db import migrations, models -import django_cryptography.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('labelbase', '0011_alter_labelbase_operation_mode'), - ] - - operations = [ - migrations.AddField( - model_name='label', - name='fee', - field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Transaction fee in satoshis (stored as string)', max_length=32, null=True)), - ), - migrations.AddField( - model_name='label', - name='fmv', - field=django_cryptography.fields.encrypt(models.TextField(blank=True, help_text='Fair market value (JSON string)', null=True)), - ), - migrations.AddField( - model_name='label', - name='height', - field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Block height where transaction was confirmed', max_length=16, null=True)), - ), - migrations.AddField( - model_name='label', - name='heights', - field=django_cryptography.fields.encrypt(models.TextField(blank=True, help_text='Block heights for address activity (JSON array as string)', null=True)), - ), - migrations.AddField( - model_name='label', - name='keypath', - field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Key derivation path (e.g., /1/123)', max_length=256, null=True)), - ), - migrations.AddField( - model_name='label', - name='rate', - field=django_cryptography.fields.encrypt(models.TextField(blank=True, help_text='Exchange rate at transaction time (JSON string)', null=True)), - ), - migrations.AddField( - model_name='label', - name='time', - field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='ISO-8601 timestamp of the block', max_length=64, null=True)), - ), - migrations.AddField( - model_name='label', - name='value', - field=django_cryptography.fields.encrypt(models.CharField(blank=True, help_text='Transaction value in satoshis, signed (stored as string)', max_length=32, null=True)), - ), - ] diff --git a/django/labelbase/models.py b/django/labelbase/models.py index 2edb72d..4020547 100644 --- a/django/labelbase/models.py +++ b/django/labelbase/models.py @@ -2,7 +2,6 @@ from django.db import models from django.contrib.auth.models import User from django.urls import reverse from django_cryptography.fields import encrypt -from django.utils.safestring import mark_safe from pymempool import MempoolAPI @@ -113,13 +112,6 @@ class Labelbase(models.Model): def get_hashtags_url(self): return reverse('labelbase_hashtags', kwargs={'labelbase_id': self.id}) - def get_xpub_url(self): - for label in self.label_set.all(): - if label.type == "xpub": # and is_valid_xpub() ... - # returns the first xpub, works for single signature only at the moment. - return reverse('edit_label', kwargs={'pk': label.id}) - return None - class Label(models.Model): """ @@ -131,7 +123,7 @@ class Label(models.Model): TYPE_PUBKEY = "pubkey" TYPE_INPUT = "input" TYPE_OUTPUT = "output" - TYPE_XPUB = "xpub" + TYPE_XPUT = "xpub" TYPE_CHOICES = [ (TYPE_TX, "tx"), @@ -139,7 +131,7 @@ class Label(models.Model): (TYPE_PUBKEY, "pubkey"), (TYPE_INPUT, "input"), (TYPE_OUTPUT, "output"), - (TYPE_XPUB, "xpub"), + (TYPE_XPUT, "xpub"), ] type = models.CharField( @@ -181,77 +173,9 @@ class Label(models.Model): ) ) - labelbase = models.ForeignKey( - Labelbase, - on_delete=models.CASCADE - ) + labelbase = models.ForeignKey(Labelbase, on_delete=models.CASCADE) - type_ref_hash = models.CharField( - max_length=64, - blank=True) - - # All additional fields (from the BIP-329 upgrade) encrypted for maximum privacy - height = encrypt( - models.CharField( - max_length=16, - null=True, - blank=True, - help_text="Block height where transaction was confirmed" - ) - ) - time = encrypt( - models.CharField( - max_length=64, - null=True, - blank=True, - help_text="ISO-8601 timestamp of the block" - ) - ) - fee = encrypt( - models.CharField( - max_length=32, - null=True, - blank=True, - help_text="Transaction fee in satoshis (stored as string)" - ) - ) - value = encrypt( - models.CharField( - max_length=32, - null=True, - blank=True, - help_text="Transaction value in satoshis, signed (stored as string)" - ) - ) - rate = encrypt( - models.TextField( - null=True, - blank=True, - help_text="Exchange rate at transaction time (JSON string)" - ) - ) - keypath = encrypt( - models.CharField( - max_length=256, - null=True, - blank=True, - help_text="Key derivation path (e.g., /1/123)" - ) - ) - fmv = encrypt( - models.TextField( - null=True, - blank=True, - help_text="Fair market value (JSON string)" - ) - ) - heights = encrypt( - models.TextField( - null=True, - blank=True, - help_text="Block heights for address activity (JSON array as string)" - ) - ) + type_ref_hash = models.CharField(max_length=64, blank=True) def get_extracted_fiat_value(self): return extract_fiat_value(self.label) @@ -277,7 +201,7 @@ class Label(models.Model): def get_absolute_url(self): """ Is used by "edit label" functionality. - This brings us back to the labelbase once the label was saved. + This brings us back to the labelbase once the lable was saved. """ return self.labelbase.get_absolute_url() @@ -299,102 +223,3 @@ class Label(models.Model): except: pass return "" - - - def get_fee_health_status(self): - """ - Calculate fee health status for this label if it's a spendable unspent output. - """ - # Only calculate for spendable outputs - if self.type != self.TYPE_OUTPUT or not self.spendable: - return { - 'status': None, - 'fee_sats': None, - 'value_sats': None, - 'fee_percentage': None, - 'threshold_healthy': None, - 'threshold_warning': None, - 'threshold_high': None - } - - try: - value_sats = int(self.value) if self.value else None - except (ValueError, TypeError): - value_sats = None - - if not value_sats or value_sats <= 0: - return { - 'status': None, - 'fee_sats': None, - 'value_sats': value_sats, - 'fee_percentage': None, - 'threshold_healthy': None, - 'threshold_warning': None, - 'threshold_high': None - } - - # Get user's fee rate from profile - user_fee_rate = self.labelbase.user.profile.my_fee # sats per vbyte - threshold_adjustment = self.labelbase.user.profile.my_fee_rate_threshold # percentage points - - # Use P2WPKH as default - most common modern type - # Simple 1-in, 2-out transaction - from finances.tx_math import calculate_transaction_size, calculate_fee - - inputs = [{'input_script': 'P2WPKH'}] - output_counts = {'p2wpkh': 2} - - tx_size = calculate_transaction_size(inputs, output_counts) - fee_sats = calculate_fee(tx_size['txVBytes'], user_fee_rate) - - # Calculate fee as percentage of output value - fee_percentage = (fee_sats / value_sats) * 100 - - # Define thresholds (base + user adjustment) - threshold_healthy = 1.0 + threshold_adjustment - threshold_warning = 3.0 + threshold_adjustment - - # Determine status - if fee_percentage < threshold_healthy: - status = 'green' - elif fee_percentage < threshold_warning: - status = 'yellow' - else: - status = 'red' - - return { - 'status': status, - 'fee_sats': fee_sats, - 'value_sats': value_sats, - 'fee_percentage': round(fee_percentage, 3), - 'threshold_healthy': threshold_healthy, - 'threshold_warning': threshold_warning, - 'threshold_high': threshold_warning - } - - - @property - def get_fee_health_status_display(self): - """ - Returns text representation of fee health status for DataTables display. - """ - health = self.get_fee_health_status() - - if not health['status']: - return '' - - status_map = { - 'green': '🟢', - 'yellow': '🟡', - 'red': '🔴' - } - # FIXME: escaping in data tables - #status_map = { - # 'green': '', - # '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 3f16523..d24f4aa 100644 --- a/django/labelbase/serializers.py +++ b/django/labelbase/serializers.py @@ -1,9 +1,8 @@ -import json from rest_framework import serializers from labelbase.models import Labelbase, Label -class LabelSerializer_v1(serializers.ModelSerializer): +class LabelSerializer(serializers.ModelSerializer): class Meta: model = Label fields = [ @@ -20,169 +19,6 @@ class LabelSerializer_v1(serializers.ModelSerializer): ] -class LabelSerializer(serializers.ModelSerializer): - # Additional BIP-329 fields - height = serializers.IntegerField(required=False, allow_null=True) - time = serializers.CharField(required=False, allow_null=True, allow_blank=True) - fee = serializers.IntegerField(required=False, allow_null=True) - value = serializers.IntegerField(required=False, allow_null=True) - rate = serializers.JSONField(required=False, allow_null=True) - keypath = serializers.CharField(required=False, allow_null=True, allow_blank=True) - fmv = serializers.JSONField(required=False, allow_null=True) - heights = serializers.ListField( - child=serializers.IntegerField(), - required=False, - allow_null=True - ) - - class Meta: - model = Label - fields = [ - "id", - "labelbase", - "type", - "ref", - "label", - "origin", - "spendable", - # Additional BIP-329 fields - "height", - "time", - "fee", - "value", - "rate", - "keypath", - "fmv", - "heights", - ] - read_only_fields = [ - "id", - ] - - def validate(self, data): - """Validate BIP-329 field combinations based on type""" - label_type = data.get('type') - - # Define valid fields per type (from BIP-329 spec) - valid_fields = { - 'tx': {'height', 'time', 'fee', 'value', 'rate'}, - 'addr': {'keypath', 'heights'}, - 'pubkey': {'keypath'}, - 'input': {'keypath', 'value', 'fmv', 'height', 'time'}, - 'output': {'spendable', 'keypath', 'value', 'fmv', 'height', 'time'}, - 'xpub': set() - } - - # Get allowed additional fields for this type - allowed = valid_fields.get(label_type, set()) - - # Check for invalid field combinations - additional_fields = {'height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights', 'spendable'} - for field in additional_fields: - if field in data and data[field] is not None: - # Allow origin for all types - if field == 'origin': - continue - # Check if field is valid for this type - if field not in allowed and field in additional_fields - {'origin'}: - # Remove invalid field instead of raising error (for compatibility) - data.pop(field, None) - - return data - - def create(self, validated_data): - """Override create to convert data types for storage""" - # Convert integers to strings for storage - if 'height' in validated_data and validated_data['height'] is not None: - validated_data['height'] = str(validated_data['height']) - - if 'fee' in validated_data and validated_data['fee'] is not None: - validated_data['fee'] = str(validated_data['fee']) - - if 'value' in validated_data and validated_data['value'] is not None: - validated_data['value'] = str(validated_data['value']) - - # Convert JSON objects to strings - if 'rate' in validated_data and validated_data['rate'] is not None: - validated_data['rate'] = json.dumps(validated_data['rate']) - - if 'fmv' in validated_data and validated_data['fmv'] is not None: - validated_data['fmv'] = json.dumps(validated_data['fmv']) - - if 'heights' in validated_data and validated_data['heights'] is not None: - validated_data['heights'] = json.dumps(validated_data['heights']) - instance = super().create(validated_data) - return instance - - def update(self, instance, validated_data): - """Override update to convert data types for storage""" - # Convert integers to strings for storage - if 'height' in validated_data and validated_data['height'] is not None: - validated_data['height'] = str(validated_data['height']) - - if 'fee' in validated_data and validated_data['fee'] is not None: - validated_data['fee'] = str(validated_data['fee']) - - if 'value' in validated_data and validated_data['value'] is not None: - validated_data['value'] = str(validated_data['value']) - - # Convert JSON objects to strings - if 'rate' in validated_data and validated_data['rate'] is not None: - validated_data['rate'] = json.dumps(validated_data['rate']) - - if 'fmv' in validated_data and validated_data['fmv'] is not None: - validated_data['fmv'] = json.dumps(validated_data['fmv']) - - if 'heights' in validated_data and validated_data['heights'] is not None: - validated_data['heights'] = json.dumps(validated_data['heights']) - - return super().update(instance, validated_data) - - def to_representation(self, instance): - """Convert stored data back to API format""" - data = super().to_representation(instance) - - # Convert string integers back to integers - if data.get('height'): - try: - data['height'] = int(data['height']) - except (ValueError, TypeError): - data['height'] = None - - if data.get('fee'): - try: - data['fee'] = int(data['fee']) - except (ValueError, TypeError): - data['fee'] = None - - if data.get('value'): - try: - data['value'] = int(data['value']) - except (ValueError, TypeError): - data['value'] = None - - # Convert JSON strings back to objects - if data.get('rate'): - try: - data['rate'] = json.loads(data['rate']) - except (json.JSONDecodeError, TypeError): - data['rate'] = None - - if data.get('fmv'): - try: - data['fmv'] = json.loads(data['fmv']) - except (json.JSONDecodeError, TypeError): - data['fmv'] = None - - if data.get('heights'): - try: - data['heights'] = json.loads(data['heights']) - except (json.JSONDecodeError, TypeError): - data['heights'] = None - - return data - - class LabelbaseSerializer(serializers.ModelSerializer): def __init__(self, *args, **kwargs): super(LabelbaseSerializer, self).__init__(*args, **kwargs) diff --git a/django/labelbase/static/Cloud.png b/django/labelbase/static/Cloud.png deleted file mode 100644 index 5882b4a..0000000 Binary files a/django/labelbase/static/Cloud.png and /dev/null differ diff --git a/django/labelbase/static/js/bootstrap.bundle.min.js b/django/labelbase/static/js/bootstrap.bundle.min.js deleted file mode 100644 index da8d852..0000000 --- a/django/labelbase/static/js/bootstrap.bundle.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * 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 deleted file mode 100644 index 156cd61..0000000 --- a/django/labelbase/static/js/feather.min.js +++ /dev/null @@ -1,13 +0,0 @@ -!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 deleted file mode 100644 index 227a349..0000000 --- a/django/messages_extends/__init__.py +++ /dev/null @@ -1,68 +0,0 @@ -from django.contrib.messages.api import MessageFailure -from messages_extends.constants import * -from django.contrib import messages - -messages.DEFAULT_TAGS.update(DEFAULT_TAGS) - -def add_message(request, level, message, extra_tags='', fail_silently=False, *args, **kwargs): - """ - Attempts to add a message to the request using the 'messages' app. - """ - if hasattr(request, '_messages'): - return request._messages.add(level, message, extra_tags, *args, **kwargs) - if not fail_silently: - raise MessageFailure('You cannot add messages without installing ' - 'django.contrib.messages.middleware.MessageMiddleware') - -messages.add_message = add_message - - -def persistant_debug(request, message, extra_tags='', fail_silently=False, *args, **kwargs): - """ - Adds a persistant message with the ``DEBUG`` level. - """ - add_message(request, DEBUG_PERSISTENT, message, extra_tags=extra_tags, - fail_silently=fail_silently, *args, **kwargs) - -messages.persistant_debug = persistant_debug - - -def persistant_info(request, message, extra_tags='', fail_silently=False, *args, **kwargs): - """ - Adds a persistant message with the ``INFO`` level. - """ - add_message(request, INFO_PERSISTENT, message, extra_tags=extra_tags, - fail_silently=fail_silently, *args, **kwargs) - -messages.persistant_info = persistant_info - - -def persistant_success(request, message, extra_tags='', fail_silently=False, *args, **kwargs): - """ - Adds a persistant message with the ``SUCCESS`` level. - """ - add_message(request, SUCCESS_PERSISTENT, message, extra_tags=extra_tags, - fail_silently=fail_silently, *args, **kwargs) - -messages.persistant_success = persistant_success - - -def persistant_warning(request, message, extra_tags='', fail_silently=False, *args, **kwargs): - """ - Adds a persistant message with the ``WARNING`` level. - """ - add_message(request, WARNING_PERSISTENT, message, extra_tags=extra_tags, - fail_silently=fail_silently, *args, **kwargs) - -messages.persistant_warning = persistant_warning - - -def persistant_error(request, message, extra_tags='', fail_silently=False, *args, **kwargs): - """ - Adds a persistant message with the ``ERROR`` level. - """ - add_message(request, ERROR_PERSISTENT, message, extra_tags=extra_tags, - fail_silently=fail_silently, *args, **kwargs) - -messages.persistant_error = persistant_error - diff --git a/django/messages_extends/admin.py b/django/messages_extends/admin.py deleted file mode 100644 index c27473b..0000000 --- a/django/messages_extends/admin.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -"""admin.py: messages extends""" - -from messages_extends.models import Message -from django.contrib import admin - -class MessageAdmin(admin.ModelAdmin): - list_display = ['level', 'user', 'message', 'created', 'read'] - -admin.site.register(Message, MessageAdmin) diff --git a/django/messages_extends/constants.py b/django/messages_extends/constants.py deleted file mode 100644 index 2c1dfd0..0000000 --- a/django/messages_extends/constants.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -"""constants.py: messages extends""" - - -DEBUG = 10 -INFO = 20 -SUCCESS = 25 -WARNING = 30 -ERROR = 40 - -DEBUG_PERSISTENT = 11 -INFO_PERSISTENT = 21 -SUCCESS_PERSISTENT = 26 -WARNING_PERSISTENT = 31 -ERROR_PERSISTENT = 41 - -DEBUG_STICKY = 12 -INFO_STICKY = 22 -SUCCESS_STICKY = 27 -WARNING_STICKY = 32 -ERROR_STICKY = 42 - -DEFAULT_TAGS = { - DEBUG_PERSISTENT: 'debug persistent', - INFO_PERSISTENT: 'info persistent', - SUCCESS_PERSISTENT: 'success persistent', - WARNING_PERSISTENT: 'warning persistent', - ERROR_PERSISTENT: 'error persistent', - - DEBUG_STICKY: 'debug sticky', - INFO_STICKY: 'info sticky', - SUCCESS_STICKY: 'success sticky', - WARNING_STICKY: 'warning sticky', - ERROR_STICKY: 'error sticky', - -} - -PERSISTENT_MESSAGE_LEVELS = ( - DEBUG_PERSISTENT, INFO_PERSISTENT, SUCCESS_PERSISTENT, WARNING_PERSISTENT, ERROR_PERSISTENT -) -STICKY_MESSAGE_LEVELS = ( - DEBUG_STICKY, INFO_STICKY, SUCCESS_STICKY, WARNING_STICKY, ERROR_STICKY -) diff --git a/django/messages_extends/exceptions.py b/django/messages_extends/exceptions.py deleted file mode 100644 index 9c67684..0000000 --- a/django/messages_extends/exceptions.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -"""admin.py: messages extends""" - -__author__ = 'ali' - -class LevelOfMessageException(Exception): - - def __init__(self, *args, **kwargs): - super(LevelOfMessageException, self).__init__(*args, **kwargs) - - def __str__(self): - return "The level of the message, can't be proccess by this storage" diff --git a/django/messages_extends/migrations/0001_initial.py b/django/messages_extends/migrations/0001_initial.py deleted file mode 100644 index a2c5d09..0000000 --- a/django/messages_extends/migrations/0001_initial.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: utf-8 -*- -from django.db import models, migrations -from django.conf import settings - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Message', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('message', models.TextField()), - ('level', models.IntegerField(choices=[(11, 'PERSISTENT DEBUG'), (21, 'PERSISTENT INFO'), (26, 'PERSISTENT SUCCESS'), (31, 'PERSISTENT WARNING'), (41, 'PERSISTENT ERROR')])), - ('extra_tags', models.CharField(max_length=128)), - ('created', models.DateTimeField(auto_now_add=True)), - ('modified', models.DateTimeField(auto_now=True)), - ('read', models.BooleanField(default=False)), - ('expires', models.DateTimeField(null=True, blank=True)), - ('user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), - ], - ), - ] diff --git a/django/messages_extends/migrations/0002_alter_message_id.py b/django/messages_extends/migrations/0002_alter_message_id.py deleted file mode 100644 index c0acf1e..0000000 --- a/django/messages_extends/migrations/0002_alter_message_id.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 3.2.25 on 2024-07-31 06:59 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('messages_extends', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='message', - name='id', - field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), - ), - ] diff --git a/django/messages_extends/migrations/__init__.py b/django/messages_extends/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/django/messages_extends/models.py b/django/messages_extends/models.py deleted file mode 100644 index 2b3026a..0000000 --- a/django/messages_extends/models.py +++ /dev/null @@ -1,72 +0,0 @@ -# -*- coding: utf-8 -*- -"""models.py: messages extends""" - -import messages_extends -from django.db import models -from django.utils.encoding import force_str -from django.contrib.messages import utils -from django.conf import settings - -LEVEL_TAGS = utils.get_level_tags() - -class Message(models.Model): - user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, - on_delete=models.CASCADE) - message = models.TextField() - LEVEL_CHOICES = ( - (messages_extends.DEBUG_PERSISTENT, 'PERSISTENT DEBUG'), - (messages_extends.INFO_PERSISTENT, 'PERSISTENT INFO'), - (messages_extends.SUCCESS_PERSISTENT, 'PERSISTENT SUCCESS'), - (messages_extends.WARNING_PERSISTENT, 'PERSISTENT WARNING'), - (messages_extends.ERROR_PERSISTENT, 'PERSISTENT ERROR'), - ) - level = models.IntegerField(choices=LEVEL_CHOICES) - extra_tags = models.CharField(max_length=128) - created = models.DateTimeField(auto_now_add=True) - modified = models.DateTimeField(auto_now=True) - read = models.BooleanField(default=False) - expires = models.DateTimeField(null=True, blank=True) - - def __eq__(self, other): - return isinstance(other, Message) and self.level == other.level and\ - self.message == other.message - - __hash__ = models.Model.__hash__ - - def __str__(self): - return force_str(self.message) - - def _prepare_message(self): - """ - Prepares the message for saving by forcing the ``message`` - and ``extra_tags`` and ``subject`` to unicode in case they are lazy translations. - - Known "safe" types (None, int, etc.) are not converted (see Django's - ``force_str`` implementation for details). - """ - self.message = force_str(self.message, strings_only=True) - self.extra_tags = force_str(self.extra_tags, strings_only=True) - - def save(self, *args, **kwargs): - self._prepare_message() - super(Message, self).save(*args, **kwargs) - - def _get_tags(self): - label_tag = force_str(LEVEL_TAGS.get(self.level, ''), - strings_only=True) - extra_tags = force_str(self.extra_tags, strings_only=True) - - if self.read: - read_tag = "read" - else: - read_tag = "unread" - - if extra_tags and label_tag: - return u' '.join([extra_tags, label_tag, read_tag]) - elif extra_tags: - return u' '.join([extra_tags, read_tag]) - elif label_tag: - return u' '.join([label_tag, read_tag]) - return read_tag - - tags = property(_get_tags) diff --git a/django/messages_extends/static/close-alerts.js b/django/messages_extends/static/close-alerts.js deleted file mode 100644 index bc33960..0000000 --- a/django/messages_extends/static/close-alerts.js +++ /dev/null @@ -1,9 +0,0 @@ -$(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 deleted file mode 100644 index 5b91d36..0000000 --- a/django/messages_extends/storages.py +++ /dev/null @@ -1,231 +0,0 @@ -# -*- coding: utf-8 -*- -"""storages.py: messages extends""" - -from django.utils.module_loading import import_string as get_storage -from django.contrib.messages.storage.base import BaseStorage, Message -from django.conf import settings -from messages_extends.models import Message as PersistentMessage -from messages_extends.constants import PERSISTENT_MESSAGE_LEVELS, STICKY_MESSAGE_LEVELS -from django.contrib.auth.models import AnonymousUser -from django.db.models import Q -try: - from django.utils import timezone -except ImportError: - from datetime import datetime as timezone - -__author__ = 'ali' - -class FallbackStorage(BaseStorage): - """ - Tries to store all messages in the first backend, storing any unstored - messages in each subsequent backend backend, by default use - MESSAGES_STORAGES = ('messages_extends.storages.StickyStorage', - 'messages_extends.storages.PersistentStorage', - 'django.contrib.messages.storage.session.CookieStorage', - 'django.contrib.messages.storage.session.SessionStorage')) - if you want change the backends, put your custom storages: - MESSAGES_STORAGES = ('foo.your_storage', 'cookie_storage') - in your settings - """ - - storages_names = getattr(settings, 'MESSAGES_STORAGES', - ('messages_extends.storages.StickyStorage', - 'messages_extends.storages.PersistentStorage', - 'django.contrib.messages.storage.cookie.CookieStorage', - 'django.contrib.messages.storage.session.SessionStorage')) - - def __init__(self, *args, **kwargs): - super(FallbackStorage, self).__init__(*args, **kwargs) - - # get instances of classes of storages_names - self.storages = [get_storage(storage)(*args, **kwargs) - for storage in self.storages_names] - - self._used_storages = set() - - def _get(self, *args, **kwargs): - """ - Gets a single list of messages from all storage backends. - """ - all_messages = [] - for storage in self.storages: - messages, all_retrieved = storage._get() - # If the backend hasn't been used, no more retrieval is necessary. - if messages is None: - break - if messages: - self._used_storages.add(storage) - all_messages.extend(messages) - # If this storage class contained all the messages, no further - # retrieval is necessary - if all_retrieved: - break - return all_messages, all_retrieved - - def _store(self, messages, response, *args, **kwargs): - """ - Stores the messages, returning any unstored messages after trying all - backends. - - For each storage backend, any messages not stored are passed on to the - next backend. - """ - for storage in self.storages: - if messages: - messages = storage._store(messages, response, - remove_oldest=False) - # Even if there are no more messages, continue iterating to ensure - # storages which contained messages are flushed. - elif storage in self._used_storages: - storage._store([], response) - self._used_storages.remove(storage) - return messages - - - def add(self, level, message, extra_tags='', *args, **kwargs): - """ - Queues a message to be stored. - - The message is only queued if it contained something and its level is - not less than the recording level (``self.level``). - """ - if not message: - return - # Check that the message level is not less than the recording level. - level = int(level) - if level < self.level: - return - # Add the message - self.added_new = True - message = Message(level, message, extra_tags=extra_tags) - for storage in self.storages: - if hasattr(storage, 'process_message'): - message = storage.process_message(message, *args, **kwargs) - if not message: - return - self._queued_messages.append(message) - - def _prepare_messages(self, messages): - """ - Prepares a list of messages for storage. - """ - for message in messages: - if hasattr(message, '_prepare'): - message._prepare() - - -class PersistentStorage(BaseStorage): - """ - Save persistent messages in data base - """ - - def __init__(self, request, *args, **kwargs): - self._sticky_messages = [] - super(PersistentStorage, self).__init__(request, *args, **kwargs) - - def _message_queryset(self, include_read=False): - """ - Return a queryset of messages for the request user - """ - expire = timezone.now() - - - qs = PersistentMessage.objects.\ - filter(user=self.get_user()).\ - filter(Q(expires=None) | Q(expires__gt=expire)) - if not include_read: - qs = qs.exclude(read=True) - return qs - - - def _get(self, *args, **kwargs): - """ - Retrieves a list of stored messages. Returns a tuple of the messages - and a flag indicating whether or not all the messages originally - intended to be stored in this storage were, in fact, stored and - retrieved; e.g., ``(messages, all_retrieved)``. - """ - is_authenticated = self.get_user().is_authenticated - if callable(is_authenticated): - is_authenticated = is_authenticated() - if is_authenticated is not True: - return [], False - return self._message_queryset(), False - - def _store(self, messages, response, *args, **kwargs): - #There are alredy saved. - return [message for message in messages if not message.level in PERSISTENT_MESSAGE_LEVELS] - - def process_message(self, message, *args, **kwargs): - """ - If its level is into persist levels, convert the message to models and save it - """ - if not message.level in PERSISTENT_MESSAGE_LEVELS: - return message - - user = kwargs.get("user") or self.get_user() - - try: - anonymous = user.is_anonymous() - except TypeError: - anonymous = user.is_anonymous - if anonymous: - raise NotImplementedError('Persistent message levels cannot be used for anonymous users.') - message_persistent = PersistentMessage() - message_persistent.level = message.level - message_persistent.message = message.message - message_persistent.extra_tags = message.extra_tags - message_persistent.user = user - - if "expires" in kwargs: - message_persistent.expires = kwargs["expires"] - message_persistent.save() - return None - - def add(self, level, message, extra_tags='', *args, **kwargs): - """ - Queues a message to be stored. - - The message is only queued if it contained something and its level is - not less than the recording level (``self.level``). - """ - if not message: - return - # Check that the message level is not less than the recording level. - level = int(level) - if level < self.level: - return - # Add the message. - self.added_new = True - message = Message(level, message, extra_tags=extra_tags) - message = self.process_message(message, *args, **kwargs) - if message: - self._queued_messages.append(message) - - def get_user(self): - if hasattr(self.request, 'user'): - return self.request.user - else: - return AnonymousUser() - - -class StickyStorage(BaseStorage): - """ - Keep messages that are sticky in memory - """ - - def __init__(self, request, *args, **kwargs): - super(StickyStorage, self).__init__(request, *args, **kwargs) - - def _get(self, *args, **kwargs): - """ - Retrieves a list of messages from the memory. - """ - return [], False - - def _store(self, messages, response, *args, **kwargs): - """ - Delete all messages that are sticky and return the other messages - This storage never save objects - """ - return [message for message in messages if not message.level in STICKY_MESSAGE_LEVELS] diff --git a/django/messages_extends/templates/messages_extends/includes/alerts_bootstrap.html b/django/messages_extends/templates/messages_extends/includes/alerts_bootstrap.html deleted file mode 100644 index f344383..0000000 --- a/django/messages_extends/templates/messages_extends/includes/alerts_bootstrap.html +++ /dev/null @@ -1,7 +0,0 @@ -{% 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 deleted file mode 100644 index b4618bf..0000000 --- a/django/messages_extends/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf-8 -*- -"""urls.py: messages extends""" - -from django.urls import re_path, path -from messages_extends.views import message_mark_all_read, message_mark_read - -app_name = 'messages_extends' - -urlpatterns = [ - re_path(r'^mark_read/(?P\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 deleted file mode 100644 index a44f8e4..0000000 --- a/django/messages_extends/views.py +++ /dev/null @@ -1,34 +0,0 @@ -# -*- coding: utf-8 -*- -"""views.py: messages extends""" - -from messages_extends.models import Message -from django.http import HttpResponse, HttpResponseRedirect -from django.shortcuts import get_object_or_404 -from django.core.exceptions import PermissionDenied - - -def callable_or_bool(fn): - if callable(fn): - return fn() - return fn - - -def message_mark_read(request, message_id): - if not callable_or_bool(request.user.is_authenticated): - raise PermissionDenied - message = get_object_or_404(Message, user=request.user, pk=message_id) - message.read = True - message.save() - if not request.headers.get('x-requested-with') == 'XMLHttpRequest': - return HttpResponseRedirect(request.META.get('HTTP_REFERER') or '/') - else: - return HttpResponse('') - -def message_mark_all_read(request): - if not callable_or_bool(request.user.is_authenticated): - raise PermissionDenied - Message.objects.filter(user=request.user).update(read=True) - if not request.headers.get('x-requested-with') == 'XMLHttpRequest': - return HttpResponseRedirect(request.META.get('HTTP_REFERER') or '/') - else: - return HttpResponse('') diff --git a/django/notifications/__init__.py b/django/notifications/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/django/notifications/utils.py b/django/notifications/utils.py deleted file mode 100644 index 066b34d..0000000 --- a/django/notifications/utils.py +++ /dev/null @@ -1,39 +0,0 @@ -from django.contrib.auth.models import User -from messages_extends.models import Message -from messages_extends import constants -""" -DEBUG = 10 -INFO = 20 -SUCCESS = 25 -WARNING = 30 -ERROR = 40 - -DEBUG_PERSISTENT = 11 -INFO_PERSISTENT = 21 -SUCCESS_PERSISTENT = 26 -WARNING_PERSISTENT = 31 -ERROR_PERSISTENT = 41 - -DEBUG_STICKY = 12 -INFO_STICKY = 22 -SUCCESS_STICKY = 27 -WARNING_STICKY = 32 -ERROR_STICKY = 42 - -""" -def notify_user(username, message, level=constants.SUCCESS_PERSISTENT): - u = User.objects.get(username=username) - m = Message(user=u, message=message, level=level) - m.save() - -def notify_success_persistent(username, message): - notify_user(username, message, level=constants.SUCCESS_PERSISTENT) - -def notify_warning_persistent(username, message): - notify_user(username, message, level=constants.WARNING_PERSISTENT) - -def notify_error_persistent(username, message): - notify_user(username, message, level=constants.ERROR_PERSISTENT) - -def notify_error(username, message): - notify_user(username, message, level=constants.ERROR) diff --git a/django/requirements.txt b/django/requirements.txt index d6e115f..7ffd1a4 100644 --- a/django/requirements.txt +++ b/django/requirements.txt @@ -1,14 +1,12 @@ gunicorn -asgiref -certifi -cffi -charset-normalizer +asgiref==3.4.1 +certifi==2023.07.22 +cffi==1.15.1 +charset-normalizer==2.0.12 coreapi==2.3.3 coreschema==0.0.4 cryptography==42.0.4 -pycryptodome Django==3.2.25 -#Django==4.2.26 django-appconf==1.0.5 django-bootstrap-form==3.4 django-classy-tags==2.0.0 @@ -25,10 +23,10 @@ django-phonenumber-field==6.0.0 django-sekizai==2.0.0 django-user-sessions==2.0.0 djangorestframework==3.14.0 -idna==3.7 +idna==3.4 importlib-metadata==4.8.3 itypes==1.2.0 -Jinja2==3.1.4 +Jinja2==3.1.3 Markdown==3.3.7 MarkupSafe==2.0.1 mysqlclient>=2.0,<3 @@ -38,12 +36,12 @@ qrcode==7.3.1 requests==2.31.0 sentry-sdk==1.43.0 six==1.16.0 -sqlparse==0.5.0 +sqlparse==0.4.4 typing_extensions==4.1.1 uritemplate==4.1.1 -urllib3>=2.6.0 +urllib3==1.26.18 zipp==3.6.0 -bip329==1.0.0 +bip329==0.0.3 pymempool==0.0.5 django-datatables-view==1.20.0 django-money==3.3 diff --git a/django/shared/__init__.py b/django/shared/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/django/shared/encryption.py b/django/shared/encryption.py deleted file mode 100644 index 9d418c7..0000000 --- a/django/shared/encryption.py +++ /dev/null @@ -1,31 +0,0 @@ -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.backends import default_backend -from cryptography.fernet import Fernet -import base64 -import json -from django.conf import settings - -def get_fernet_key(secret_key, salt): - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=salt, - iterations=100000, - backend=default_backend() - ) - key = base64.urlsafe_b64encode(kdf.derive(secret_key.encode())) - return Fernet(key) - -# Create a Fernet cipher suite using the derived key -cipher_suite = get_fernet_key(settings.SECRET_KEY, settings.CRYPTOGRAPHY_SALT) - -def encrypt_data(data): - json_data = json.dumps(data).encode('utf-8') - encrypted_data = cipher_suite.encrypt(json_data) - return encrypted_data.decode('utf-8') - -def decrypt_data(encrypted_data): - encrypted_data_bytes = encrypted_data.encode('utf-8') - decrypted_data = cipher_suite.decrypt(encrypted_data_bytes) - return json.loads(decrypted_data.decode('utf-8')) diff --git a/django/templates/_base.html b/django/templates/_base.html index 2169017..ff0e0d1 100644 --- a/django/templates/_base.html +++ b/django/templates/_base.html @@ -19,14 +19,8 @@ - {% if user.is_authenticated %} - {% for labelbase in user.profile.labelbases %} - {% if labelbase.id == active_labelbase_id %} - {% if labelbase.name %}{{ labelbase.name }}{% endif %} - {% endif %} - {% endfor %} - {% endif %} - {% block title %}{% endblock %} | Labelbase + + {% block title %}{% endblock %} | Labelbase - - @@ -90,6 +64,7 @@ + diff --git a/django/templates/_intro.html b/django/templates/_intro.html index e536e8e..8332de6 100644 --- a/django/templates/_intro.html +++ b/django/templates/_intro.html @@ -95,6 +95,11 @@ + + diff --git a/django/templates/_labelbase_header_info_menu.html b/django/templates/_labelbase_header_info_menu.html index 77b9b88..7f65bbe 100644 --- a/django/templates/_labelbase_header_info_menu.html +++ b/django/templates/_labelbase_header_info_menu.html @@ -8,42 +8,37 @@ {% endif %}
-
- - - - - - - - + + {% comment %} + + Export + {% endcomment %}
- - - - - +
diff --git a/django/templates/_modal_add_label.html b/django/templates/_modal_add_label.html index d6354b6..2c3ce4b 100644 --- a/django/templates/_modal_add_label.html +++ b/django/templates/_modal_add_label.html @@ -1,4 +1,5 @@ {% load bootstrap %} +
- - - - - - diff --git a/django/templates/_modal_connect_api_key.html b/django/templates/_modal_connect_api_key.html deleted file mode 100644 index f0366a9..0000000 --- a/django/templates/_modal_connect_api_key.html +++ /dev/null @@ -1,35 +0,0 @@ - diff --git a/django/templates/_modal_delete_labelbase.html b/django/templates/_modal_delete_labelbase.html deleted file mode 100644 index 53d2976..0000000 --- a/django/templates/_modal_delete_labelbase.html +++ /dev/null @@ -1,26 +0,0 @@ - diff --git a/django/templates/_modal_edit_labelbase.html b/django/templates/_modal_edit_labelbase.html deleted file mode 100644 index 1b12ce1..0000000 --- a/django/templates/_modal_edit_labelbase.html +++ /dev/null @@ -1,24 +0,0 @@ -{% load i18n %} -{% load labelbase_tags %} -{% load bootstrap %} - diff --git a/django/templates/_modal_importWizzardModal.html b/django/templates/_modal_importWizzardModal.html index 2769f12..3217cc1 100644 --- a/django/templates/_modal_importWizzardModal.html +++ b/django/templates/_modal_importWizzardModal.html @@ -18,19 +18,9 @@
- {% addtoblock "js" %} - $(document).ready(function () { - var form = $('#importLabelbaseModal form'); - var importTypeField = $('#id_import_type'); - var passphraseFieldGroup = $('#id_passphrase').closest('.form-group'); - - function togglePassphraseField() { - if (importTypeField.val() === 'samourai') { - passphraseFieldGroup.show(); - $('#id_passphrase').prop('disabled', false); - $('#samourai-warning').show(); - } else { - passphraseFieldGroup.hide(); - $('#id_passphrase').prop('disabled', true); - $('#id_passphrase').val('') - $('#samourai-warning').hide(); - } - } - - importTypeField.on('change', togglePassphraseField); - - // Initial call to set the correct state on page load - togglePassphraseField(); - - form.on('submit', function () { - // Disable the OK button on form submission - $('#importLabelbaseModal button[type="submit"]').prop('disabled', true); - }); + var form = $('#importLabelbaseModal form'); + form.on('submit', function () { + // Disable the OK button on form submission + $('#importLabelbaseModal button[type="submit"]').prop('disabled', true); }); - +}); {% endaddtoblock %} diff --git a/django/templates/attachments/add.html b/django/templates/attachments/add.html index 13f507b..79667f3 100644 --- a/django/templates/attachments/add.html +++ b/django/templates/attachments/add.html @@ -1,4 +1,5 @@ {% extends "attachments/base.html" %} + {% block content %} {% include "attachments/add_form.html" %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/django/templates/attachments/add_form.html b/django/templates/attachments/add_form.html index 9414688..96a43a9 100644 --- a/django/templates/attachments/add_form.html +++ b/django/templates/attachments/add_form.html @@ -8,5 +8,7 @@ {{ form|bootstrap }}
+ + {% endif %} diff --git a/django/templates/cloud.html b/django/templates/cloud.html deleted file mode 100644 index 347533c..0000000 --- a/django/templates/cloud.html +++ /dev/null @@ -1,51 +0,0 @@ -{% 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 deleted file mode 100644 index 4c31915..0000000 --- a/django/templates/currency_sync.html +++ /dev/null @@ -1,212 +0,0 @@ -{% 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 dc6a32d..3056ac5 100644 --- a/django/templates/donate.html +++ b/django/templates/donate.html @@ -7,106 +7,22 @@ {% 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. +

- -

-

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

-
- - 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 f9ddf90..1388885 100644 --- a/django/templates/electrum_server_info_update.html +++ b/django/templates/electrum_server_info_update.html @@ -22,7 +22,6 @@

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 c925c51..be512f6 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,8 +18,11 @@
-
- +
+ ...

Your labels are encrypted.
 

diff --git a/django/templates/fill_missing_data.html b/django/templates/fill_missing_data.html deleted file mode 100644 index b1d3f93..0000000 --- a/django/templates/fill_missing_data.html +++ /dev/null @@ -1,128 +0,0 @@ -{% 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 e49663e..b69639f 100644 --- a/django/templates/home.html +++ b/django/templates/home.html @@ -268,11 +268,27 @@
-

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.

+

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.

- + {% 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

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

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 8f660bc..4ae51cc 100644 --- a/django/templates/knowledge_base/article_detail.html +++ b/django/templates/knowledge_base/article_detail.html @@ -53,8 +53,7 @@ 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 3618cc4..febd64f 100644 --- a/django/templates/knowledge_base/category_detail.html +++ b/django/templates/knowledge_base/category_detail.html @@ -8,7 +8,8 @@ {#% 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 5dc6140..2cedc8e 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 deleted file mode 100644 index 76be5ef..0000000 --- a/django/templates/label_derive_addresses.html +++ /dev/null @@ -1,171 +0,0 @@ -{% extends "label_edit.html" %} -{% load bootstrap %} -{% load sekizai_tags %} -{% load i18n %} -{% load labelbase_tags %} - - -{% block label_edit_content %} - -{% include "_modal_add_label.html" %} - - - - - -
- -
-
- -
-
- - -
-
- - -
-
- - -
- -
- -
-
- - {{ form.instance.ref }} - -
- - - -
- - - - - - - - - - - - -
- -
- -
- - - - - - - -{% endblock %} diff --git a/django/templates/label_edit.html b/django/templates/label_edit.html index 5830f6f..ae6f434 100644 --- a/django/templates/label_edit.html +++ b/django/templates/label_edit.html @@ -5,71 +5,106 @@ {% load backgroundtask_tags %} {% load attachments_tags %} -{% block title %}{{ object.type }} {{ object.ref }}{% endblock %} +{% block title %}{{ object.type }} {{ object.ref }}{% endblock %} + {% block content %} {% get_attachments_for object.get_label_attachment as my_attachments %} +
+
-{% addtoblock "js" %} -// BIP-329 field visibility based on type -const fieldsByType = { - 'tx': ['origin', 'height', 'time', 'fee', 'value', 'rate'], - 'addr': ['origin', 'keypath', 'heights'], - 'pubkey': ['origin', 'keypath'], - 'input': ['origin', 'keypath', 'value', 'fmv', 'height', 'time'], - 'output': ['origin', 'spendable', 'keypath', 'value', 'fmv', 'height', 'time'], - 'xpub': ['origin'] -}; - -function updateFieldVisibility() { - const typeField = document.getElementById('id_type'); - if (!typeField) return; - - const selectedType = typeField.value; - const allowedFields = fieldsByType[selectedType] || []; - - // All additional fields (excluding core fields: type, ref, label) - const allAdditionalFields = ['origin', 'spendable', 'height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights']; - - allAdditionalFields.forEach(fieldName => { - const field = document.getElementById(`id_${fieldName}`); - if (field) { - // Find the parent form-group/control-group div - const wrapper = field.closest('.form-group') || field.closest('.control-group') || field.closest('.mb-3') || field.parentElement.parentElement; - - if (wrapper) { - if (allowedFields.includes(fieldName)) { - wrapper.style.display = ''; - field.removeAttribute('disabled'); - } else { - wrapper.style.display = 'none'; - field.setAttribute('disabled', 'disabled'); - field.value = ''; // Clear hidden fields - } - } - } - }); -} - -// Run on page load -document.addEventListener('DOMContentLoaded', function() { - updateFieldVisibility(); - - // Run when type changes - const typeField = document.getElementById('id_type'); - if (typeField) { - typeField.addEventListener('change', updateFieldVisibility); - } -}); - -// Validate fmv field (JSON object with currency codes) -const fmvField = document.getElementById('id_fmv'); -if (fmvField) { - fmvField.addEventListener('blur', function() { - try { - if (this.value && this.value.trim()) { - const parsed = JSON.parse(this.value); - if (typeof parsed !== 'object' || Array.isArray(parsed)) { - alert('FMV must be a JSON object like {"USD": 1233.45}'); - return; - } - // Validate values are numeric (accept both numbers and numeric strings) - for (const [key, value] of Object.entries(parsed)) { - // Check if it's a number OR a numeric string - const isNumeric = typeof value === 'number' || - (typeof value === 'string' && !isNaN(parseFloat(value)) && isFinite(value)); - if (!isNumeric) { - alert(`FMV value for ${key} must be numeric (got: ${typeof value})`); - return; - } - } - } - } catch (e) { - alert('Invalid JSON format for fmv field'); - } - }); -} - -// Validate rate field (JSON object with currency codes) -const rateField = document.getElementById('id_rate'); -if (rateField) { - rateField.addEventListener('blur', function() { - try { - if (this.value && this.value.trim()) { - const parsed = JSON.parse(this.value); - if (typeof parsed !== 'object' || Array.isArray(parsed)) { - alert('Rate must be a JSON object like {"USD": 105620.00}'); - return; - } - // Validate values are numeric (accept both numbers and numeric strings) - for (const [key, value] of Object.entries(parsed)) { - // Check if it's a number OR a numeric string - const isNumeric = typeof value === 'number' || - (typeof value === 'string' && !isNaN(parseFloat(value)) && isFinite(value)); - if (!isNumeric) { - alert(`Rate value for ${key} must be numeric (got: ${typeof value})`); - return; - } - } - } - } catch (e) { - alert('Invalid JSON format for rate field'); - } - }); -} - -// Validate heights field (JSON array of integers) -const heightsField = document.getElementById('id_heights'); -if (heightsField) { - heightsField.addEventListener('blur', function() { - try { - if (this.value && this.value.trim()) { - const parsed = JSON.parse(this.value); - if (!Array.isArray(parsed)) { - alert('Heights must be a JSON array like [123456, 789012]'); - return; - } - // Validate all values are integers - for (const height of parsed) { - if (!Number.isInteger(height)) { - alert('All heights must be integers'); - return; - } - } - } - } catch (e) { - alert('Invalid JSON format for heights field'); - } - }); -} - -// Validate integer fields (height, fee, value) -const integerFields = ['id_height', 'id_fee', 'id_value']; -integerFields.forEach(fieldId => { - const field = document.getElementById(fieldId); - if (field) { - field.addEventListener('blur', function() { - if (this.value && this.value.trim()) { - if (!/^-?\d+$/.test(this.value.trim())) { - alert(`${fieldId.replace('id_', '')} must be an integer`); - } - } - }); - } -}); - -{% endaddtoblock %} + + {% endblock %} diff --git a/django/templates/label_edit_labeling.html b/django/templates/label_edit_labeling.html index 35416fc..034cbcc 100644 --- a/django/templates/label_edit_labeling.html +++ b/django/templates/label_edit_labeling.html @@ -168,8 +168,6 @@ + + + {% endif %} + + {% endblock %} diff --git a/django/templates/labelbase_tree_maps.html b/django/templates/labelbase_tree_maps.html index b18d500..36e80e1 100644 --- a/django/templates/labelbase_tree_maps.html +++ b/django/templates/labelbase_tree_maps.html @@ -16,12 +16,6 @@ - diff --git a/django/templates/labelbase_tree_maps_unspent_outputs.html b/django/templates/labelbase_tree_maps_unspent_outputs.html index 326ceb2..ceeb33a 100644 --- a/django/templates/labelbase_tree_maps_unspent_outputs.html +++ b/django/templates/labelbase_tree_maps_unspent_outputs.html @@ -11,26 +11,14 @@ -{% 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 %} + +{% if labelbase.is_testnet %}
Important Notice for Testnet Transactions: Testnet coins hold no real-world value and are solely intended for testing purposes.
-
+
@@ -40,7 +28,6 @@ If the UTXO value is not provided, Labelbase will estimate it based on historica
{% endif %} -