mirror of
https://github.com/Labelbase/Labelbase.git
synced 2026-08-13 12:33:23 +02:00
wip
This commit is contained in:
parent
b5c0b7531f
commit
fff7ac548c
14 changed files with 952 additions and 204 deletions
|
|
@ -27,6 +27,9 @@ def process_uploaded_data(uploaded_data_id, passphrase=None, loop=None):
|
|||
if buf in EOLSTOP:
|
||||
break
|
||||
data = json.loads(buf)
|
||||
|
||||
logger.info(f"Parsed data: {data}")
|
||||
|
||||
data["labelbase"] = labelbase.id
|
||||
serializer = LabelSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from django.db import models
|
|||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from django_cryptography.fields import encrypt
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from pymempool import MempoolAPI
|
||||
|
||||
|
|
@ -298,3 +299,102 @@ class Label(models.Model):
|
|||
except:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def get_fee_health_status(self):
|
||||
"""
|
||||
Calculate fee health status for this label if it's a spendable unspent output.
|
||||
"""
|
||||
# Only calculate for spendable outputs
|
||||
if self.type != self.TYPE_OUTPUT or not self.spendable:
|
||||
return {
|
||||
'status': None,
|
||||
'fee_sats': None,
|
||||
'value_sats': None,
|
||||
'fee_percentage': None,
|
||||
'threshold_healthy': None,
|
||||
'threshold_warning': None,
|
||||
'threshold_high': None
|
||||
}
|
||||
|
||||
try:
|
||||
value_sats = int(self.value) if self.value else None
|
||||
except (ValueError, TypeError):
|
||||
value_sats = None
|
||||
|
||||
if not value_sats or value_sats <= 0:
|
||||
return {
|
||||
'status': None,
|
||||
'fee_sats': None,
|
||||
'value_sats': value_sats,
|
||||
'fee_percentage': None,
|
||||
'threshold_healthy': None,
|
||||
'threshold_warning': None,
|
||||
'threshold_high': None
|
||||
}
|
||||
|
||||
# Get user's fee rate from profile
|
||||
user_fee_rate = self.labelbase.user.profile.my_fee # sats per vbyte
|
||||
threshold_adjustment = self.labelbase.user.profile.my_fee_rate_threshold # percentage points
|
||||
|
||||
# Use P2WPKH as default - most common modern type
|
||||
# Simple 1-in, 2-out transaction
|
||||
from finances.tx_math import calculate_transaction_size, calculate_fee
|
||||
|
||||
inputs = [{'input_script': 'P2WPKH'}]
|
||||
output_counts = {'p2wpkh': 2}
|
||||
|
||||
tx_size = calculate_transaction_size(inputs, output_counts)
|
||||
fee_sats = calculate_fee(tx_size['txVBytes'], user_fee_rate)
|
||||
|
||||
# Calculate fee as percentage of output value
|
||||
fee_percentage = (fee_sats / value_sats) * 100
|
||||
|
||||
# Define thresholds (base + user adjustment)
|
||||
threshold_healthy = 1.0 + threshold_adjustment
|
||||
threshold_warning = 3.0 + threshold_adjustment
|
||||
|
||||
# Determine status
|
||||
if fee_percentage < threshold_healthy:
|
||||
status = 'green'
|
||||
elif fee_percentage < threshold_warning:
|
||||
status = 'yellow'
|
||||
else:
|
||||
status = 'red'
|
||||
|
||||
return {
|
||||
'status': status,
|
||||
'fee_sats': fee_sats,
|
||||
'value_sats': value_sats,
|
||||
'fee_percentage': round(fee_percentage, 3),
|
||||
'threshold_healthy': threshold_healthy,
|
||||
'threshold_warning': threshold_warning,
|
||||
'threshold_high': threshold_warning
|
||||
}
|
||||
|
||||
|
||||
@property
|
||||
def get_fee_health_status_display(self):
|
||||
"""
|
||||
Returns text representation of fee health status for DataTables display.
|
||||
"""
|
||||
health = self.get_fee_health_status()
|
||||
|
||||
if not health['status']:
|
||||
return ''
|
||||
|
||||
status_map = {
|
||||
'green': '🟢',
|
||||
'yellow': '🟡',
|
||||
'red': '🔴'
|
||||
}
|
||||
# FIXME: escaping in data tables
|
||||
#status_map = {
|
||||
# 'green': '<span data-feather="circle-check" class="align-text-bottom"></span>',
|
||||
# 'yellow': '<span data-feather="alert-circle" class="align-text-bottom"></span>',
|
||||
# 'red': '<span color:red; data-feather="alert-triangle" class="align-text-bottom"></span>'
|
||||
#}
|
||||
|
||||
emoji = status_map.get(health['status'], '')
|
||||
|
||||
return f"{emoji} {health['fee_percentage']}%"
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import json
|
||||
from rest_framework import serializers
|
||||
from labelbase.models import Labelbase, Label
|
||||
|
||||
|
||||
class LabelSerializer(serializers.ModelSerializer):
|
||||
class LabelSerializer_v1(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Label
|
||||
fields = [
|
||||
|
|
@ -19,6 +20,169 @@ class LabelSerializer(serializers.ModelSerializer):
|
|||
]
|
||||
|
||||
|
||||
class LabelSerializer(serializers.ModelSerializer):
|
||||
# Additional BIP-329 fields
|
||||
height = serializers.IntegerField(required=False, allow_null=True)
|
||||
time = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
fee = serializers.IntegerField(required=False, allow_null=True)
|
||||
value = serializers.IntegerField(required=False, allow_null=True)
|
||||
rate = serializers.JSONField(required=False, allow_null=True)
|
||||
keypath = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
fmv = serializers.JSONField(required=False, allow_null=True)
|
||||
heights = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Label
|
||||
fields = [
|
||||
"id",
|
||||
"labelbase",
|
||||
"type",
|
||||
"ref",
|
||||
"label",
|
||||
"origin",
|
||||
"spendable",
|
||||
# Additional BIP-329 fields
|
||||
"height",
|
||||
"time",
|
||||
"fee",
|
||||
"value",
|
||||
"rate",
|
||||
"keypath",
|
||||
"fmv",
|
||||
"heights",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
"""Validate BIP-329 field combinations based on type"""
|
||||
label_type = data.get('type')
|
||||
|
||||
# Define valid fields per type (from BIP-329 spec)
|
||||
valid_fields = {
|
||||
'tx': {'height', 'time', 'fee', 'value', 'rate'},
|
||||
'addr': {'keypath', 'heights'},
|
||||
'pubkey': {'keypath'},
|
||||
'input': {'keypath', 'value', 'fmv', 'height', 'time'},
|
||||
'output': {'spendable', 'keypath', 'value', 'fmv', 'height', 'time'},
|
||||
'xpub': set()
|
||||
}
|
||||
|
||||
# Get allowed additional fields for this type
|
||||
allowed = valid_fields.get(label_type, set())
|
||||
|
||||
# Check for invalid field combinations
|
||||
additional_fields = {'height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights', 'spendable'}
|
||||
for field in additional_fields:
|
||||
if field in data and data[field] is not None:
|
||||
# Allow origin for all types
|
||||
if field == 'origin':
|
||||
continue
|
||||
# Check if field is valid for this type
|
||||
if field not in allowed and field in additional_fields - {'origin'}:
|
||||
# Remove invalid field instead of raising error (for compatibility)
|
||||
data.pop(field, None)
|
||||
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Override create to convert data types for storage"""
|
||||
# Convert integers to strings for storage
|
||||
if 'height' in validated_data and validated_data['height'] is not None:
|
||||
validated_data['height'] = str(validated_data['height'])
|
||||
|
||||
if 'fee' in validated_data and validated_data['fee'] is not None:
|
||||
validated_data['fee'] = str(validated_data['fee'])
|
||||
|
||||
if 'value' in validated_data and validated_data['value'] is not None:
|
||||
validated_data['value'] = str(validated_data['value'])
|
||||
|
||||
# Convert JSON objects to strings
|
||||
if 'rate' in validated_data and validated_data['rate'] is not None:
|
||||
validated_data['rate'] = json.dumps(validated_data['rate'])
|
||||
|
||||
if 'fmv' in validated_data and validated_data['fmv'] is not None:
|
||||
validated_data['fmv'] = json.dumps(validated_data['fmv'])
|
||||
|
||||
if 'heights' in validated_data and validated_data['heights'] is not None:
|
||||
validated_data['heights'] = json.dumps(validated_data['heights'])
|
||||
instance = super().create(validated_data)
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Override update to convert data types for storage"""
|
||||
# Convert integers to strings for storage
|
||||
if 'height' in validated_data and validated_data['height'] is not None:
|
||||
validated_data['height'] = str(validated_data['height'])
|
||||
|
||||
if 'fee' in validated_data and validated_data['fee'] is not None:
|
||||
validated_data['fee'] = str(validated_data['fee'])
|
||||
|
||||
if 'value' in validated_data and validated_data['value'] is not None:
|
||||
validated_data['value'] = str(validated_data['value'])
|
||||
|
||||
# Convert JSON objects to strings
|
||||
if 'rate' in validated_data and validated_data['rate'] is not None:
|
||||
validated_data['rate'] = json.dumps(validated_data['rate'])
|
||||
|
||||
if 'fmv' in validated_data and validated_data['fmv'] is not None:
|
||||
validated_data['fmv'] = json.dumps(validated_data['fmv'])
|
||||
|
||||
if 'heights' in validated_data and validated_data['heights'] is not None:
|
||||
validated_data['heights'] = json.dumps(validated_data['heights'])
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Convert stored data back to API format"""
|
||||
data = super().to_representation(instance)
|
||||
|
||||
# Convert string integers back to integers
|
||||
if data.get('height'):
|
||||
try:
|
||||
data['height'] = int(data['height'])
|
||||
except (ValueError, TypeError):
|
||||
data['height'] = None
|
||||
|
||||
if data.get('fee'):
|
||||
try:
|
||||
data['fee'] = int(data['fee'])
|
||||
except (ValueError, TypeError):
|
||||
data['fee'] = None
|
||||
|
||||
if data.get('value'):
|
||||
try:
|
||||
data['value'] = int(data['value'])
|
||||
except (ValueError, TypeError):
|
||||
data['value'] = None
|
||||
|
||||
# Convert JSON strings back to objects
|
||||
if data.get('rate'):
|
||||
try:
|
||||
data['rate'] = json.loads(data['rate'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
data['rate'] = None
|
||||
|
||||
if data.get('fmv'):
|
||||
try:
|
||||
data['fmv'] = json.loads(data['fmv'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
data['fmv'] = None
|
||||
|
||||
if data.get('heights'):
|
||||
try:
|
||||
data['heights'] = json.loads(data['heights'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
data['heights'] = None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class LabelbaseSerializer(serializers.ModelSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LabelbaseSerializer, self).__init__(*args, **kwargs)
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ from userprofile.views import (ProfileView,
|
|||
ProfileAvatarUpdateView,
|
||||
ProfileCurrencyUpdateView,
|
||||
MempoolUpdateView,
|
||||
ProfileFeeUpdateView,
|
||||
ElectrumInfoUpdateView)
|
||||
from userprofile.views import APIKeyView
|
||||
from userprofile.views import has_seen_welcome_popup
|
||||
|
||||
|
||||
|
||||
from hashtags.views import HashtagListView, HashtagUpdateView, LabelbaseProxyView
|
||||
|
||||
|
||||
|
|
@ -46,8 +49,13 @@ from .views import (
|
|||
#LabelbasePortfolioView,
|
||||
OutputStatUpdateRedirectView,
|
||||
BitcoinAddressDatatableView,
|
||||
CurrencySyncView,
|
||||
CurrencySyncActionView,
|
||||
FillMissingDataView,
|
||||
FillMissingDataActionView
|
||||
)
|
||||
|
||||
|
||||
from importer.views import upload_labels
|
||||
from django.contrib.auth import views as auth_views
|
||||
|
||||
|
|
@ -93,6 +101,11 @@ urlpatterns = [
|
|||
login_required(MempoolUpdateView.as_view()),
|
||||
name="userprofile_mempool",
|
||||
),
|
||||
path(
|
||||
"account/userprofile-mempool-fees/",
|
||||
login_required(ProfileFeeUpdateView.as_view()),
|
||||
name="userprofile_fees",
|
||||
),
|
||||
path(
|
||||
"account/userprofile-currency/",
|
||||
login_required(ProfileCurrencyUpdateView.as_view()),
|
||||
|
|
@ -150,6 +163,27 @@ urlpatterns = [
|
|||
login_required(LabelbaseMergeView.as_view()),
|
||||
name="labelbase_merge"
|
||||
),
|
||||
path(
|
||||
'labelbase/<int:labelbase_id>/currency-sync/',
|
||||
CurrencySyncView.as_view(),
|
||||
name='currency_sync'
|
||||
),
|
||||
path(
|
||||
'labelbase/<int:labelbase_id>/currency-sync/action/',
|
||||
CurrencySyncActionView.as_view(),
|
||||
name='currency_sync_action'
|
||||
),
|
||||
path(
|
||||
'labelbase/<int:labelbase_id>/fill-missing-data/',
|
||||
FillMissingDataView.as_view(),
|
||||
name='fill_missing_data'
|
||||
),
|
||||
path(
|
||||
'labelbase/<int:labelbase_id>/fill-missing-data/action/',
|
||||
FillMissingDataActionView.as_view(),
|
||||
name='fill_missing_data_action'
|
||||
),
|
||||
|
||||
#path(
|
||||
# "labelbase/<int:labelbase_id>/portfolio/",
|
||||
# login_required(LabelbasePortfolioView.as_view()),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import os
|
||||
import time
|
||||
import re
|
||||
import tempfile
|
||||
import json
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.forms import UserCreationForm
|
||||
from django.shortcuts import redirect, resolve_url
|
||||
|
|
@ -27,20 +32,17 @@ from labelbase.forms import LabelForm, LabelbaseForm
|
|||
from labelbase.forms import ExportLabelsForm
|
||||
from finances.models import OutputStat
|
||||
from finances.tasks import check_all_outputs
|
||||
from finances.models import HistoricalPrice
|
||||
from .utils import hashtag_to_badge, extract_fiat_value
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
from embit import bip32, script
|
||||
from embit.networks import NETWORKS
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django_datatables_view.base_datatable_view import BaseDatatableView
|
||||
from embit import bip32, script
|
||||
from embit.networks import NETWORKS
|
||||
|
||||
|
||||
logger = logging.getLogger('labelbase')
|
||||
|
||||
DEFAULT_DERIVE_ADDRESS_COUNT = 100
|
||||
|
||||
class BitcoinAddressDatatableView(BaseDatatableView):
|
||||
|
|
@ -300,6 +302,7 @@ class LabelbaseDatatableView(BaseDatatableView):
|
|||
|
||||
def filter_queryset(self, qs):
|
||||
search = self.request.GET.get('search[value]', None)
|
||||
type_filter = self.request.GET.get('type', None)
|
||||
if search:
|
||||
# Due to encryption, we need to use a super slow process here...
|
||||
res_ids = []
|
||||
|
|
@ -317,7 +320,9 @@ class LabelbaseDatatableView(BaseDatatableView):
|
|||
if record.origin and search in record.origin.lower():
|
||||
res_ids.append(record.id)
|
||||
continue
|
||||
return qs.filter(id__in=res_ids)
|
||||
qs = qs.filter(id__in=res_ids)
|
||||
if type_filter and type_filter != 'all':
|
||||
qs = qs.filter(type=type_filter)
|
||||
return qs
|
||||
|
||||
|
||||
|
|
@ -724,6 +729,210 @@ class FixAndMergeLabelsView(View):
|
|||
})
|
||||
|
||||
|
||||
class CurrencySyncView(View):
|
||||
template_name = "currency_sync.html"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
labelbase_id = self.kwargs["labelbase_id"]
|
||||
labelbase = get_object_or_404(Labelbase, id=labelbase_id, user_id=request.user.id)
|
||||
|
||||
# Get all output and input labels (types that support fmv)
|
||||
labels = Label.objects.filter(
|
||||
labelbase_id=labelbase_id,
|
||||
type__in=['output', 'input']
|
||||
)
|
||||
|
||||
# Categorize
|
||||
text_only = [] # Has currency in label, no FMV
|
||||
fmv_only = [] # Has FMV, no currency in label
|
||||
conflicts = [] # Both exist but don't match
|
||||
synced = [] # Both exist and match
|
||||
|
||||
for label in labels:
|
||||
label_currency = extract_fiat_value(label.label) # (value, currency)
|
||||
fmv_data = self._parse_fmv(label.fmv) # Parse JSON
|
||||
|
||||
has_label_currency = label_currency[0] > 0
|
||||
has_fmv = fmv_data is not None
|
||||
|
||||
if has_label_currency and not has_fmv:
|
||||
text_only.append(label)
|
||||
elif has_fmv and not has_label_currency:
|
||||
fmv_only.append(label)
|
||||
elif has_label_currency and has_fmv:
|
||||
if self._currencies_match(label_currency, fmv_data):
|
||||
synced.append(label)
|
||||
else:
|
||||
conflicts.append(label)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'labelbase': labelbase,
|
||||
'text_only': text_only,
|
||||
'fmv_only': fmv_only,
|
||||
'conflicts': conflicts,
|
||||
'synced': synced,
|
||||
'active_labelbase_id': labelbase_id,
|
||||
})
|
||||
|
||||
def _parse_fmv(self, fmv_str):
|
||||
"""Parse FMV JSON string"""
|
||||
if not fmv_str or not fmv_str.strip():
|
||||
return None
|
||||
try:
|
||||
return json.loads(fmv_str)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return None
|
||||
|
||||
def _currencies_match(self, label_currency, fmv_data):
|
||||
"""Check if label currency matches FMV"""
|
||||
value, currency = label_currency
|
||||
|
||||
if currency not in fmv_data:
|
||||
return False
|
||||
|
||||
# Get FMV value and convert to Decimal if it's a string
|
||||
fmv_value = fmv_data[currency]
|
||||
if isinstance(fmv_value, str):
|
||||
try:
|
||||
fmv_value = Decimal(fmv_value)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
else:
|
||||
fmv_value = Decimal(str(fmv_value))
|
||||
|
||||
# Convert label value to Decimal if needed
|
||||
if not isinstance(value, Decimal):
|
||||
value = Decimal(str(value))
|
||||
|
||||
# Compare with small tolerance for rounding differences
|
||||
return abs(fmv_value - value) < Decimal('0.01')
|
||||
|
||||
|
||||
|
||||
#import json
|
||||
#from django.views import View
|
||||
#from django.shortcuts import get_object_or_404, redirect
|
||||
#from django.http import HttpResponseRedirect
|
||||
#from django.urls import reverse
|
||||
#from django.contrib import messages
|
||||
#from labelbase.models import Label
|
||||
#from labellabor.utils import extract_fiat_value
|
||||
|
||||
|
||||
class CurrencySyncActionView(View):
|
||||
def post(self, request, *args, **kwargs):
|
||||
labelbase_id = self.kwargs["labelbase_id"]
|
||||
label_id = request.POST.get('label_id')
|
||||
action = request.POST.get('action')
|
||||
|
||||
if action == 'sync_all_text_to_fmv':
|
||||
# Batch sync: text → FMV
|
||||
labels = Label.objects.filter(
|
||||
labelbase_id=labelbase_id,
|
||||
labelbase__user_id=request.user.id,
|
||||
type__in=['output', 'input']
|
||||
)
|
||||
|
||||
count = 0
|
||||
for label in labels:
|
||||
value, currency = extract_fiat_value(label.label)
|
||||
if value > 0 and currency:
|
||||
# Convert Decimal to string, then create dict, then JSON
|
||||
fmv_dict = {currency: str(value)}
|
||||
label.fmv = json.dumps(fmv_dict)
|
||||
label.save()
|
||||
count += 1
|
||||
|
||||
messages.success(request, f"Synced {count} labels from text to FMV field.")
|
||||
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
|
||||
|
||||
elif action == 'sync_all_fmv_to_text':
|
||||
labels = Label.objects.filter(
|
||||
labelbase_id=labelbase_id,
|
||||
labelbase__user_id=request.user.id,
|
||||
type__in=['output', 'input']
|
||||
)
|
||||
count = 0
|
||||
for label in labels:
|
||||
if label.fmv and label.fmv.strip():
|
||||
try:
|
||||
fmv_data = json.loads(label.fmv)
|
||||
if fmv_data:
|
||||
currency, value_str = list(fmv_data.items())[0]
|
||||
value = Decimal(value_str) if isinstance(value_str, str) else Decimal(str(value_str))
|
||||
existing_value, existing_currency = extract_fiat_value(label.label)
|
||||
if existing_value == 0 or not existing_currency:
|
||||
if label.label:
|
||||
label.label = f"{label.label} {currency} {value:.2f}".strip()
|
||||
else:
|
||||
label.label = f"{currency} {value:.2f}".strip()
|
||||
label.save()
|
||||
count += 1
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logging.warning(f"Error processing label {label.id}: {e}")
|
||||
continue
|
||||
|
||||
messages.success(request, f"Synced {count} labels from FMV to text field.")
|
||||
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
|
||||
|
||||
# Single label actions
|
||||
if not label_id:
|
||||
messages.error(request, "No label specified.")
|
||||
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
|
||||
|
||||
label = get_object_or_404(
|
||||
Label,
|
||||
id=label_id,
|
||||
labelbase__user_id=request.user.id
|
||||
)
|
||||
|
||||
if action == 'text_to_fmv':
|
||||
# Extract from label text and populate FMV
|
||||
value, currency = extract_fiat_value(label.label)
|
||||
if value > 0 and currency:
|
||||
# Store as string to preserve precision
|
||||
fmv_dict = {currency: str(value)}
|
||||
label.fmv = json.dumps(fmv_dict)
|
||||
label.save()
|
||||
messages.success(request, f"Synced currency from label text to FMV field.")
|
||||
else:
|
||||
messages.warning(request, "No valid currency found in label text.")
|
||||
|
||||
elif action == 'fmv_to_text':
|
||||
# Extract from FMV and update label text
|
||||
if label.fmv and label.fmv.strip():
|
||||
try:
|
||||
fmv_data = json.loads(label.fmv)
|
||||
if fmv_data:
|
||||
currency, value_str = list(fmv_data.items())[0]
|
||||
# Parse value back from string
|
||||
value = Decimal(value_str) if isinstance(value_str, str) else Decimal(str(value_str))
|
||||
|
||||
# Check if currency already in label
|
||||
existing_value, existing_currency = extract_fiat_value(label.label)
|
||||
if existing_value == 0 or not existing_currency: # No currency in label
|
||||
# Append to label with proper decimal formatting
|
||||
if label.label:
|
||||
label.label = f"{label.label} {currency} {value:.2f}".strip()
|
||||
else:
|
||||
label.label = f"{currency} {value:.2f}".strip()
|
||||
label.save()
|
||||
messages.success(request, f"Synced currency from FMV to label text.")
|
||||
else:
|
||||
# Pattern to match currency and value like "CHF 615.00" or "USD 1000.50"
|
||||
pattern = r'\b' + re.escape(existing_currency) + r'\s+\d+\.?\d*\b'
|
||||
new_text = f"{currency} {value:.2f}"
|
||||
label.label = re.sub(pattern, new_text, label.label)
|
||||
label.save()
|
||||
messages.success(request, f"Updated currency in label text from FMV.")
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
messages.error(request, f"Error parsing FMV data: {e}")
|
||||
else:
|
||||
messages.warning(request, "No FMV data found.")
|
||||
|
||||
return HttpResponseRedirect(reverse('currency_sync', kwargs={'labelbase_id': labelbase_id}))
|
||||
|
||||
|
||||
class ExportLabelsView(View):
|
||||
def post(self, request, *args, **kwargs):
|
||||
labelbase_id = self.kwargs["labelbase_id"]
|
||||
|
|
@ -783,11 +992,54 @@ class ExportLabelsView(View):
|
|||
"ref": label.ref,
|
||||
"label": label.label,
|
||||
}
|
||||
|
||||
if label.origin and label.type == "tx":
|
||||
label_entry["origin"] = label.origin
|
||||
if label.spendable in [True, False] and label.type == "output":
|
||||
label_entry["spendable"] = label.spendable
|
||||
# Additional BIP-329 fields with robust error handling
|
||||
# Integer fields (height, fee, value)
|
||||
if label.height:
|
||||
try:
|
||||
label_entry["height"] = int(label.height)
|
||||
except (ValueError, TypeError) as e:
|
||||
logging.warning(f"Invalid height value for label {label.id}: {e}")
|
||||
|
||||
if label.time:
|
||||
label_entry["time"] = label.time
|
||||
|
||||
if label.fee:
|
||||
try:
|
||||
label_entry["fee"] = int(label.fee)
|
||||
except (ValueError, TypeError) as e:
|
||||
logging.warning(f"Invalid fee value for label {label.id}: {e}")
|
||||
|
||||
if label.value:
|
||||
try:
|
||||
label_entry["value"] = int(label.value)
|
||||
except (ValueError, TypeError) as e:
|
||||
logging.warning(f"Invalid value for label {label.id}: {e}")
|
||||
|
||||
# JSON fields (rate, fmv, heights)
|
||||
if label.rate and label.rate.strip():
|
||||
try:
|
||||
label_entry["rate"] = json.loads(label.rate)
|
||||
except json.JSONDecodeError as e:
|
||||
logging.warning(f"Invalid rate JSON for label {label.id}: {e}")
|
||||
|
||||
if label.keypath:
|
||||
label_entry["keypath"] = label.keypath
|
||||
|
||||
if label.fmv and label.fmv.strip():
|
||||
try:
|
||||
label_entry["fmv"] = json.loads(label.fmv)
|
||||
except json.JSONDecodeError as e:
|
||||
logging.warning(f"Invalid fmv JSON for label {label.id}: {e}")
|
||||
|
||||
if label.heights and label.heights.strip():
|
||||
try:
|
||||
label_entry["heights"] = json.loads(label.heights)
|
||||
except json.JSONDecodeError as e:
|
||||
logging.warning(f"Invalid heights JSON for label {label.id}: {e}")
|
||||
|
||||
# Write the label entry to the file
|
||||
label_writer.write_label(label_entry)
|
||||
|
|
@ -971,3 +1223,128 @@ class OutputStatUpdateRedirectView(View):
|
|||
)
|
||||
label.save() # will trigger a check agains Electrum
|
||||
return redirect('edit_label', pk=label_id)
|
||||
|
||||
|
||||
class FillMissingDataView(View):
|
||||
template_name = "fill_missing_data.html"
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
labelbase_id = self.kwargs["labelbase_id"]
|
||||
labelbase = get_object_or_404(Labelbase, id=labelbase_id, user_id=request.user.id)
|
||||
|
||||
# Get all output labels (only type that uses OutputStat)
|
||||
output_labels = Label.objects.filter(
|
||||
labelbase_id=labelbase_id,
|
||||
type='output'
|
||||
)
|
||||
|
||||
can_fill_from_outputstat = []
|
||||
already_complete = []
|
||||
|
||||
for label in output_labels:
|
||||
missing_fields = self._get_missing_fields(label)
|
||||
if not missing_fields:
|
||||
already_complete.append(label)
|
||||
continue
|
||||
|
||||
# Check if OutputStat exists
|
||||
output_stat = OutputStat.objects.filter(
|
||||
user=label.labelbase.user,
|
||||
type_ref_hash=label.type_ref_hash,
|
||||
network=label.labelbase.network
|
||||
).first()
|
||||
|
||||
if output_stat:
|
||||
can_fill_from_outputstat.append({
|
||||
'label': label,
|
||||
'missing_fields': missing_fields,
|
||||
'output_stat': output_stat
|
||||
})
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'labelbase': labelbase,
|
||||
'can_fill_from_outputstat': can_fill_from_outputstat,
|
||||
'already_complete': already_complete,
|
||||
'active_labelbase_id': labelbase_id,
|
||||
})
|
||||
|
||||
def _get_missing_fields(self, label):
|
||||
"""Return list of fields that are missing for output type"""
|
||||
missing = []
|
||||
|
||||
# Only check fields applicable to outputs
|
||||
applicable_fields = ['height', 'time', 'value']
|
||||
|
||||
for field in applicable_fields:
|
||||
value = getattr(label, field, None)
|
||||
if not value or (isinstance(value, str) and not value.strip()):
|
||||
missing.append(field)
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
|
||||
class FillMissingDataActionView(View):
|
||||
def post(self, request, *args, **kwargs):
|
||||
labelbase_id = self.kwargs["labelbase_id"]
|
||||
action = request.POST.get('action')
|
||||
label_id = request.POST.get('label_id')
|
||||
if action == 'fill_all_from_outputstat':
|
||||
count = self._fill_all_from_outputstat(request.user.id, labelbase_id)
|
||||
messages.success(request, f"Filled {count} labels from OutputStat data.")
|
||||
elif action == 'fill_single_from_outputstat':
|
||||
label = get_object_or_404(Label, id=label_id, labelbase__user_id=request.user.id)
|
||||
if self._fill_label_from_outputstat(label):
|
||||
messages.success(request, "Filled label data from OutputStat.")
|
||||
else:
|
||||
messages.error(request, "Could not fill label data.")
|
||||
return HttpResponseRedirect(reverse('fill_missing_data', kwargs={'labelbase_id': labelbase_id}))
|
||||
|
||||
def _fill_all_from_outputstat(self, user_id, labelbase_id):
|
||||
"""Fill all output labels that have OutputStat data"""
|
||||
labels = Label.objects.filter(
|
||||
labelbase_id=labelbase_id,
|
||||
labelbase__user_id=user_id,
|
||||
type='output'
|
||||
)
|
||||
count = 0
|
||||
for label in labels:
|
||||
if self._fill_label_from_outputstat(label):
|
||||
count += 1
|
||||
|
||||
return count
|
||||
|
||||
def _fill_label_from_outputstat(self, label):
|
||||
"""Fill a single label from OutputStat data"""
|
||||
output_stat = OutputStat.objects.filter(
|
||||
user=label.labelbase.user,
|
||||
type_ref_hash=label.type_ref_hash,
|
||||
network=label.labelbase.network
|
||||
).first()
|
||||
|
||||
if not output_stat:
|
||||
return False
|
||||
|
||||
updated = False
|
||||
|
||||
# Fill height (only if empty)
|
||||
if not label.height and output_stat.confirmed_at_block_height:
|
||||
label.height = str(output_stat.confirmed_at_block_height)
|
||||
updated = True
|
||||
|
||||
# Fill time (only if empty)
|
||||
if not label.time and output_stat.confirmed_at_block_time:
|
||||
# Convert Unix timestamp to ISO-8601
|
||||
dt = datetime.utcfromtimestamp(output_stat.confirmed_at_block_time)
|
||||
label.time = dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
updated = True
|
||||
|
||||
# Fill value (only if empty)
|
||||
if not label.value and output_stat.value:
|
||||
label.value = str(output_stat.value)
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
label.save()
|
||||
|
||||
return updated
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ six==1.16.0
|
|||
sqlparse==0.5.0
|
||||
typing_extensions==4.1.1
|
||||
uritemplate==4.1.1
|
||||
urllib3==1.26.18
|
||||
urllib3>=2.6.0
|
||||
zipp==3.6.0
|
||||
bip329==1.0.0
|
||||
pymempool==0.0.5
|
||||
|
|
|
|||
|
|
@ -184,11 +184,12 @@
|
|||
{% endif %}
|
||||
{% endwith %}
|
||||
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_fix_and_manage" labelbase_id=labelbase.id %}"><span data-feather="git-merge" class="align-text-bottom"></span> Fix & Manage</a></li>
|
||||
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "fill_missing_data" labelbase_id=labelbase.id %}"><span data-feather="refresh-cw" class="align-text-bottom"></span> Sync Fields</a></li>
|
||||
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_health" labelbase_id=labelbase.id %}"><span data-feather="activity" class="align-text-bottom"></span>UTXOs Health</a></li>{% endcomment %}
|
||||
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_tree_maps" pk=labelbase.id %}unspent-outputs/"><span data-feather="grid" class="align-text-bottom"></span> Tree Map</a></li>
|
||||
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_stats_and_kpi" labelbase_id=labelbase.id %}"><span data-feather="pie-chart" class="align-text-bottom"></span> Stats & KPIs</a></li>{% endcomment %}
|
||||
{% if request.user.profile.use_fiatfinances %}
|
||||
<li><a class="nav-link" style="font-size: .875rem;"><span data-feather="dollar-sign" class="align-text-bottom"></span> Fiat Finances</a></li>
|
||||
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "currency_sync" labelbase_id=labelbase.id %}"><span data-feather="dollar-sign" class="align-text-bottom"></span> Fiat Finances</a></li>
|
||||
{% endif %}
|
||||
<li><a class="nav-link" style="font-size: .875rem;" data-bs-toggle="modal" data-bs-target="#importLabelbaseModal"><span data-feather="upload-cloud" class="align-text-bottom"></span> Import</a></li>
|
||||
<li><a class="nav-link" style="font-size: .875rem;" data-bs-toggle="modal" data-bs-target="#exportLabelbaseModal"><span data-feather="download-cloud" class="align-text-bottom"></span> Export</a></li>
|
||||
|
|
|
|||
|
|
@ -304,7 +304,34 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col d-flex align-items-start">
|
||||
<div>
|
||||
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">BIP-329 Extended Fields</h3>
|
||||
<p>Full support for BIP-329 additional fields including transaction height, timestamp, fees, values, exchange rates, and derivation paths. Enrich your labels with comprehensive transaction metadata.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col d-flex align-items-start">
|
||||
<div>
|
||||
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Currency Sync Tool</h3>
|
||||
<p>Seamlessly synchronize between legacy currency annotations in label text and structured FMV fields. Automatically detect and resolve conflicts to maintain data consistency across formats.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col d-flex align-items-start">
|
||||
<div>
|
||||
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Auto-Fill Missing Data</h3>
|
||||
<p>Automatically populate transaction metadata from OutputStat records. Fill block heights, timestamps, and values for your outputs with one click, saving time and ensuring accuracy.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col d-flex align-items-start">
|
||||
<div>
|
||||
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis">Fee Health Monitoring</h3>
|
||||
<p>Instantly visualize the cost-effectiveness of spending your UTXOs. Labelbase calculates and displays fee health status for each spendable output, color-coded to show if transaction fees would consume a healthy percentage of the output value.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col d-flex align-items-start">
|
||||
<div>
|
||||
<h3 class="fw-bold mb-0 fs-4 text-body-emphasis"><br> <br> </h3>
|
||||
|
|
|
|||
|
|
@ -5,43 +5,12 @@
|
|||
{% load backgroundtask_tags %}
|
||||
{% load attachments_tags %}
|
||||
|
||||
{% block title %}{{ object.type }} {{ object.ref }}{% endblock %}
|
||||
|
||||
|
||||
{% comment %}<!--
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if action == "detail" %}active" aria-current="page" {% else %}"{% endif %} href="{% url 'edit_label' object.id %}labeling/">Transaction Detail</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if action == "history" %}active" aria-current="page"{% else %}"{% endif %} href="{#% url 'edit_label' object.id %#}">Label History</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Transaction Output Labeling</a>
|
||||
</li>
|
||||
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if action == "labeling" %}active" aria-current="page" {% else %}"{% endif %} href="{% url 'edit_label' object.id %}labeling/">Derived Addresses Labeling</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Coin Value</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Coin History</a>
|
||||
</li>
|
||||
|
||||
|
||||
|
||||
<div class="col-{% if action == "derive-addresses-void" %}10{% else %}12{% endif %} float-start">
|
||||
|
||||
-->{% endcomment %}
|
||||
{% block title %}{{ object.type }} {{ object.ref }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% get_attachments_for object.get_label_attachment as my_attachments %}
|
||||
|
||||
|
||||
<div class="row">
|
||||
<ul class="nav nav-tabs" style="padding-top: 2rem;">
|
||||
<li class="nav-item">
|
||||
|
|
@ -120,51 +89,6 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% comment %}
|
||||
{% is_label_id_in_queue object.id as is_in_queue %}
|
||||
{% if is_in_queue %}
|
||||
<div class="alert bd-callout bd-callout-info">
|
||||
<!--div class="btn-group" role="group" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger dropdown-toggle " data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Actions
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=none">Verify output status</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=true">Mark output as spent</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=false">Mark output as unspent</a></li>
|
||||
</ul>
|
||||
</div-->
|
||||
<strong>Output in queue!</strong> This output is currently in the processing queue. It will be checked shortly.
|
||||
</div>
|
||||
{% else %}
|
||||
{% if object.type == "output" %}
|
||||
{% switch output.get_spent_status %}
|
||||
{% case "spent" %}
|
||||
<div class="bd-callout bd-callout-warning">
|
||||
<strong>Output spent!</strong> Blockchain records indicate that this output has been spent in another transaction.
|
||||
</div>
|
||||
{% case "unspent" %}
|
||||
<div class="bd-callout bd-callout-good">
|
||||
<strong>Output unspent!</strong> Blockchain records indicate that this output has not been spent yet.
|
||||
</div>
|
||||
{% case "unconfirmed" %}
|
||||
<div class="alert bd-callout bd-callout-info">
|
||||
<div class="btn-group" role="group" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;">
|
||||
<button type="button" class="btn btn-sm btn-outline-info dropdown-toggle " data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Actions
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=none">
|
||||
Verify output status</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<strong>Output unconfirmed!</strong> Blockchain records indicate that this output has not been confirmed yet.
|
||||
</div>
|
||||
{% endswitch %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endcomment %}
|
||||
|
||||
{% is_label_id_in_queue object.id as is_in_queue %}
|
||||
{% if is_in_queue %}
|
||||
<div class="alert bd-callout bd-callout-info">
|
||||
|
|
@ -204,32 +128,11 @@
|
|||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!--div class="bd-callout bd-callout-warning">
|
||||
<strong>Heads up!</strong> There are multiple records for this transaction output. <a href="">Review & merge</a>
|
||||
</div-->
|
||||
|
||||
{% comment %}
|
||||
{% if object.type == "addr" %}
|
||||
<div class="bd-callout bd-callout-warning">
|
||||
<strong>Heads up!</strong> There are multiple transaction outputs sent to this address. For privacy reasons, do not reuse addresses.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endcomment %}
|
||||
|
||||
{% block label_edit_content %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="modal" tabindex="-1" id="deleteLabelModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
|
|
@ -252,107 +155,160 @@
|
|||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
{% addtoblock "js" %}
|
||||
|
||||
// BIP-329 field visibility based on type
|
||||
const fieldsByType = {
|
||||
'tx': ['origin', 'height', 'time', 'fee', 'value', 'rate'],
|
||||
'addr': ['origin', 'keypath', 'heights'],
|
||||
'pubkey': ['origin', 'keypath'],
|
||||
'input': ['origin', 'keypath', 'value', 'fmv', 'height', 'time'],
|
||||
'output': ['origin', 'spendable', 'keypath', 'value', 'fmv', 'height', 'time'],
|
||||
'xpub': ['origin']
|
||||
};
|
||||
|
||||
function updateFieldVisibility() {
|
||||
const typeField = document.getElementById('id_type');
|
||||
if (!typeField) return;
|
||||
|
||||
const selectedType = typeField.value;
|
||||
const allowedFields = fieldsByType[selectedType] || [];
|
||||
|
||||
<script>
|
||||
{% addtoblock "js" %}
|
||||
// All additional fields (excluding core fields: type, ref, label)
|
||||
const allAdditionalFields = ['origin', 'spendable', 'height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights'];
|
||||
|
||||
// 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
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value !== 'number') {
|
||||
alert(`Rate value for ${key} must be numeric`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Invalid JSON format for rate field');
|
||||
}
|
||||
});
|
||||
}
|
||||
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;
|
||||
|
||||
// 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
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value !== 'number') {
|
||||
alert(`FMV value for ${key} must be numeric`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Invalid JSON format for fmv field');
|
||||
}
|
||||
});
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
// Run on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateFieldVisibility();
|
||||
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// Run when type changes
|
||||
const typeField = document.getElementById('id_type');
|
||||
if (typeField) {
|
||||
typeField.addEventListener('change', updateFieldVisibility);
|
||||
}
|
||||
});
|
||||
|
||||
{% endaddtoblock %}
|
||||
</script>
|
||||
// Validate fmv field (JSON object with currency codes)
|
||||
const fmvField = document.getElementById('id_fmv');
|
||||
if (fmvField) {
|
||||
fmvField.addEventListener('blur', function() {
|
||||
try {
|
||||
if (this.value && this.value.trim()) {
|
||||
const parsed = JSON.parse(this.value);
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
alert('FMV must be a JSON object like {"USD": 1233.45}');
|
||||
return;
|
||||
}
|
||||
// Validate values are numeric (accept both numbers and numeric strings)
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
// Check if it's a number OR a numeric string
|
||||
const isNumeric = typeof value === 'number' ||
|
||||
(typeof value === 'string' && !isNaN(parseFloat(value)) && isFinite(value));
|
||||
if (!isNumeric) {
|
||||
alert(`FMV value for ${key} must be numeric (got: ${typeof value})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Invalid JSON format for fmv field');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate rate field (JSON object with currency codes)
|
||||
const rateField = document.getElementById('id_rate');
|
||||
if (rateField) {
|
||||
rateField.addEventListener('blur', function() {
|
||||
try {
|
||||
if (this.value && this.value.trim()) {
|
||||
const parsed = JSON.parse(this.value);
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
alert('Rate must be a JSON object like {"USD": 105620.00}');
|
||||
return;
|
||||
}
|
||||
// Validate values are numeric (accept both numbers and numeric strings)
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
// Check if it's a number OR a numeric string
|
||||
const isNumeric = typeof value === 'number' ||
|
||||
(typeof value === 'string' && !isNaN(parseFloat(value)) && isFinite(value));
|
||||
if (!isNumeric) {
|
||||
alert(`Rate value for ${key} must be numeric (got: ${typeof value})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Invalid JSON format for rate field');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate heights field (JSON array of integers)
|
||||
const heightsField = document.getElementById('id_heights');
|
||||
if (heightsField) {
|
||||
heightsField.addEventListener('blur', function() {
|
||||
try {
|
||||
if (this.value && this.value.trim()) {
|
||||
const parsed = JSON.parse(this.value);
|
||||
if (!Array.isArray(parsed)) {
|
||||
alert('Heights must be a JSON array like [123456, 789012]');
|
||||
return;
|
||||
}
|
||||
// Validate all values are integers
|
||||
for (const height of parsed) {
|
||||
if (!Number.isInteger(height)) {
|
||||
alert('All heights must be integers');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Invalid JSON format for heights field');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate integer fields (height, fee, value)
|
||||
const integerFields = ['id_height', 'id_fee', 'id_value'];
|
||||
integerFields.forEach(fieldId => {
|
||||
const field = document.getElementById(fieldId);
|
||||
if (field) {
|
||||
field.addEventListener('blur', function() {
|
||||
if (this.value && this.value.trim()) {
|
||||
if (!/^-?\d+$/.test(this.value.trim())) {
|
||||
alert(`${fieldId.replace('id_', '')} must be an integer`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
{% endaddtoblock %}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,31 @@
|
|||
|
||||
<div class="table-responsive d-none d-md-block" style="padding-top:1em">
|
||||
{% if label_list %}
|
||||
|
||||
<ul class="nav nav-tabs" id="typeFilterTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if not request.GET.type or request.GET.type == 'all' %}active{% endif %}" data-type="all">All</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if request.GET.type == 'tx' %}active{% endif %}" data-type="tx">Transactions</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if request.GET.type == 'output' %}active{% endif %}" data-type="output">Outputs</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if request.GET.type == 'input' %}active{% endif %}" data-type="input">Inputs</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if request.GET.type == 'addr' %}active{% endif %}" data-type="addr">Addresses</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if request.GET.type == 'pubkey' %}active{% endif %}" data-type="pubkey">Pubkeys</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if request.GET.type == 'xpub' %}active{% endif %}" data-type="xpub">XPubs</button>
|
||||
</li>
|
||||
</ul>
|
||||
<br>
|
||||
<table id="bip329labels" class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
@ -32,6 +57,8 @@
|
|||
<th scope="col">label</th>
|
||||
<th scope="col">origin</th>
|
||||
<th scope="col">spendable</th>
|
||||
<th scope="col">health</th>
|
||||
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -123,6 +150,7 @@
|
|||
<script>
|
||||
{% addtoblock "js" %}
|
||||
$(document).ready(function () {
|
||||
window.currentTypeFilter = 'all';
|
||||
const dt_table = $('#bip329labels').DataTable({
|
||||
order: [[0, "asc"]],
|
||||
columns: [
|
||||
|
|
@ -131,15 +159,19 @@
|
|||
{ data: 'ref', orderable: true, searchable: true },
|
||||
{ data: 'label', orderable: true, searchable: true },
|
||||
{ data: 'origin', orderable: true, searchable: true },
|
||||
{ data: 'spendable', orderable: true, searchable: true }
|
||||
{ data: 'spendable', orderable: true, searchable: true },
|
||||
{ data: 'get_fee_health_status_display', orderable: false, searchable: false}
|
||||
|
||||
],
|
||||
|
||||
searching: true,
|
||||
processing: false,
|
||||
serverSide: true,
|
||||
stateSave: true,
|
||||
responsive: true,
|
||||
ajax: {
|
||||
url: "{% url 'labelbase_label_data' labelbase.pk %}?tag={{ request.GET.tag }}",
|
||||
// url: "{% url 'labelbase_label_data' labelbase.pk %}?tag={{ request.GET.tag }}",
|
||||
url: "{% url 'labelbase_label_data' labelbase.pk %}?tag={{ request.GET.tag }}&type={{ request.GET.type|default:'all' }}",
|
||||
type: 'GET',
|
||||
dataSrc: 'data'
|
||||
},
|
||||
|
|
@ -151,6 +183,21 @@
|
|||
}
|
||||
});
|
||||
|
||||
// Tab click handler - full page reload
|
||||
$('#typeFilterTabs button').on('click', function() {
|
||||
const typeFilter = $(this).data('type');
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (typeFilter === 'all') {
|
||||
urlParams.delete('type');
|
||||
} else {
|
||||
urlParams.set('type', typeFilter);
|
||||
}
|
||||
|
||||
// Full page reload with new URL
|
||||
window.location.search = urlParams.toString();
|
||||
});
|
||||
|
||||
function createCard(data) {
|
||||
let originText = '';
|
||||
if (data.origin) {
|
||||
|
|
@ -160,6 +207,12 @@
|
|||
if (data.type === '<tt>output</tt>' && (data.spendable === "<tt>true</tt>" || data.spendable === "<tt>false</tt>")) {
|
||||
spendableText = `<tr><td> <strong>Spendable:</strong></td><td> ${data.spendable === "<tt>true</tt>" ? 'true' : 'false'}</td></tr>`;
|
||||
}
|
||||
let healthText = '';
|
||||
if (data.get_fee_health_status_display) {
|
||||
healthText = `<tr><td><strong>Health:</strong></td><td>${data.get_fee_health_status_display}</td></tr>`;
|
||||
}
|
||||
|
||||
|
||||
return `
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card">
|
||||
|
|
@ -184,6 +237,7 @@
|
|||
</tr>
|
||||
${originText ? `<tr><td colspan="2">${originText}</td></tr>` : ''}
|
||||
${spendableText ? `${spendableText}` : ''}
|
||||
${healthText}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -193,6 +247,7 @@
|
|||
}
|
||||
|
||||
|
||||
|
||||
function renderMobileView(data, start, length, totalRecords, totalRecordsAll) {
|
||||
$('#mobileLabels').empty();
|
||||
data.forEach(function(item) {
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
{% url 'userprofile_mempool' as mempool_url %}
|
||||
<a class="nav-link {% if request.path == mempool_url %}active" aria-current="page" {% else %}"{% endif %} href="{{ mempool_url }}">Mempool</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
{% url 'userprofile_fees' as mempool_fees_url %}
|
||||
<a class="nav-link {% if request.path == mempool_fees_url %}active" aria-current="page" {% else %}"{% endif %} href="{{ mempool_fees_url }}">Mempool Fees</a>
|
||||
</li>
|
||||
{% if request.user.profile.use_fiatfinances %}
|
||||
<li class="nav-item">
|
||||
{% url 'userprofile_currency' as my_currency_url %}
|
||||
|
|
|
|||
|
|
@ -41,4 +41,11 @@ class ProfileCurrencyForm(forms.ModelForm):
|
|||
fields = ['my_currency']
|
||||
|
||||
|
||||
|
||||
class ProfileFeeForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Profile
|
||||
fields = ['my_fee', "my_fee_rate_threshold"]
|
||||
|
||||
|
||||
#
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ class Profile(models.Model):
|
|||
choices=CURRENCY_CHOICES,
|
||||
default='USD')
|
||||
|
||||
my_fee = models.IntegerField(default=1, help_text="Fee used when broadcasting transactions")
|
||||
my_fee_rate_threshold = models.IntegerField(default=1, help_text="Overwrite healthy fee")
|
||||
has_seen_welcome_popup = models.BooleanField(default=False)
|
||||
|
||||
def labelbases(self):
|
||||
return Labelbase.objects.filter(user_id=self.user_id)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ from .forms import (
|
|||
ProfileAvatarForm,
|
||||
ProfileCurrencyForm,
|
||||
ElectrumServerInfoForm,
|
||||
MempoolForm)
|
||||
MempoolForm,
|
||||
ProfileFeeForm)
|
||||
|
||||
|
||||
|
||||
|
|
@ -135,3 +136,19 @@ class ProfileCurrencyUpdateView(UpdateView):
|
|||
def form_valid(self, form):
|
||||
messages.success(self.request, "<strong>Success!</strong> Currency updated successfully.")
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class ProfileFeeUpdateView(UpdateView):
|
||||
model = Profile
|
||||
form_class = ProfileFeeForm
|
||||
template_name = 'profile_update_fees.html'
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse_lazy('userprofile_fees')
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return get_object_or_404(Profile, user=self.request.user)
|
||||
|
||||
def form_valid(self, form):
|
||||
messages.success(self.request, "<strong>Success!</strong> Fees updated successfully.")
|
||||
return super().form_valid(form)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue