From fff7ac548c540071ccc4779ef7e1af8b93712d66 Mon Sep 17 00:00:00 2001 From: Xavier Fiechter Date: Fri, 12 Dec 2025 23:47:04 +0100 Subject: [PATCH] wip --- django/importer/tasks.py | 3 + django/labelbase/models.py | 100 ++++++ django/labelbase/serializers.py | 166 ++++++++- django/labellabor/urls.py | 34 ++ django/labellabor/views.py | 395 +++++++++++++++++++++- django/requirements.txt | 2 +- django/templates/_base.html | 3 +- django/templates/home.html | 27 ++ django/templates/label_edit.html | 334 ++++++++---------- django/templates/labelbase.html | 59 +++- django/templates/profile_header_menu.html | 4 + django/userprofile/forms.py | 7 + django/userprofile/models.py | 3 + django/userprofile/views.py | 19 +- 14 files changed, 952 insertions(+), 204 deletions(-) diff --git a/django/importer/tasks.py b/django/importer/tasks.py index 00e30aa..d147798 100644 --- a/django/importer/tasks.py +++ b/django/importer/tasks.py @@ -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(): diff --git a/django/labelbase/models.py b/django/labelbase/models.py index 6ea2139..2edb72d 100644 --- a/django/labelbase/models.py +++ b/django/labelbase/models.py @@ -2,6 +2,7 @@ from django.db import models from django.contrib.auth.models import User from django.urls import reverse from django_cryptography.fields import encrypt +from django.utils.safestring import mark_safe from pymempool import MempoolAPI @@ -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': '', + # 'yellow': '', + # 'red': '' + #} + + emoji = status_map.get(health['status'], '') + + return f"{emoji} {health['fee_percentage']}%" diff --git a/django/labelbase/serializers.py b/django/labelbase/serializers.py index d24f4aa..3f16523 100644 --- a/django/labelbase/serializers.py +++ b/django/labelbase/serializers.py @@ -1,8 +1,9 @@ +import json from rest_framework import serializers from labelbase.models import Labelbase, Label -class LabelSerializer(serializers.ModelSerializer): +class LabelSerializer_v1(serializers.ModelSerializer): class Meta: model = Label fields = [ @@ -19,6 +20,169 @@ class LabelSerializer(serializers.ModelSerializer): ] +class LabelSerializer(serializers.ModelSerializer): + # Additional BIP-329 fields + height = serializers.IntegerField(required=False, allow_null=True) + time = serializers.CharField(required=False, allow_null=True, allow_blank=True) + fee = serializers.IntegerField(required=False, allow_null=True) + value = serializers.IntegerField(required=False, allow_null=True) + rate = serializers.JSONField(required=False, allow_null=True) + keypath = serializers.CharField(required=False, allow_null=True, allow_blank=True) + fmv = serializers.JSONField(required=False, allow_null=True) + heights = serializers.ListField( + child=serializers.IntegerField(), + required=False, + allow_null=True + ) + + class Meta: + model = Label + fields = [ + "id", + "labelbase", + "type", + "ref", + "label", + "origin", + "spendable", + # Additional BIP-329 fields + "height", + "time", + "fee", + "value", + "rate", + "keypath", + "fmv", + "heights", + ] + read_only_fields = [ + "id", + ] + + def validate(self, data): + """Validate BIP-329 field combinations based on type""" + label_type = data.get('type') + + # Define valid fields per type (from BIP-329 spec) + valid_fields = { + 'tx': {'height', 'time', 'fee', 'value', 'rate'}, + 'addr': {'keypath', 'heights'}, + 'pubkey': {'keypath'}, + 'input': {'keypath', 'value', 'fmv', 'height', 'time'}, + 'output': {'spendable', 'keypath', 'value', 'fmv', 'height', 'time'}, + 'xpub': set() + } + + # Get allowed additional fields for this type + allowed = valid_fields.get(label_type, set()) + + # Check for invalid field combinations + additional_fields = {'height', 'time', 'fee', 'value', 'rate', 'keypath', 'fmv', 'heights', 'spendable'} + for field in additional_fields: + if field in data and data[field] is not None: + # Allow origin for all types + if field == 'origin': + continue + # Check if field is valid for this type + if field not in allowed and field in additional_fields - {'origin'}: + # Remove invalid field instead of raising error (for compatibility) + data.pop(field, None) + + return data + + def create(self, validated_data): + """Override create to convert data types for storage""" + # Convert integers to strings for storage + if 'height' in validated_data and validated_data['height'] is not None: + validated_data['height'] = str(validated_data['height']) + + if 'fee' in validated_data and validated_data['fee'] is not None: + validated_data['fee'] = str(validated_data['fee']) + + if 'value' in validated_data and validated_data['value'] is not None: + validated_data['value'] = str(validated_data['value']) + + # Convert JSON objects to strings + if 'rate' in validated_data and validated_data['rate'] is not None: + validated_data['rate'] = json.dumps(validated_data['rate']) + + if 'fmv' in validated_data and validated_data['fmv'] is not None: + validated_data['fmv'] = json.dumps(validated_data['fmv']) + + if 'heights' in validated_data and validated_data['heights'] is not None: + validated_data['heights'] = json.dumps(validated_data['heights']) + instance = super().create(validated_data) + return instance + + def update(self, instance, validated_data): + """Override update to convert data types for storage""" + # Convert integers to strings for storage + if 'height' in validated_data and validated_data['height'] is not None: + validated_data['height'] = str(validated_data['height']) + + if 'fee' in validated_data and validated_data['fee'] is not None: + validated_data['fee'] = str(validated_data['fee']) + + if 'value' in validated_data and validated_data['value'] is not None: + validated_data['value'] = str(validated_data['value']) + + # Convert JSON objects to strings + if 'rate' in validated_data and validated_data['rate'] is not None: + validated_data['rate'] = json.dumps(validated_data['rate']) + + if 'fmv' in validated_data and validated_data['fmv'] is not None: + validated_data['fmv'] = json.dumps(validated_data['fmv']) + + if 'heights' in validated_data and validated_data['heights'] is not None: + validated_data['heights'] = json.dumps(validated_data['heights']) + + return super().update(instance, validated_data) + + def to_representation(self, instance): + """Convert stored data back to API format""" + data = super().to_representation(instance) + + # Convert string integers back to integers + if data.get('height'): + try: + data['height'] = int(data['height']) + except (ValueError, TypeError): + data['height'] = None + + if data.get('fee'): + try: + data['fee'] = int(data['fee']) + except (ValueError, TypeError): + data['fee'] = None + + if data.get('value'): + try: + data['value'] = int(data['value']) + except (ValueError, TypeError): + data['value'] = None + + # Convert JSON strings back to objects + if data.get('rate'): + try: + data['rate'] = json.loads(data['rate']) + except (json.JSONDecodeError, TypeError): + data['rate'] = None + + if data.get('fmv'): + try: + data['fmv'] = json.loads(data['fmv']) + except (json.JSONDecodeError, TypeError): + data['fmv'] = None + + if data.get('heights'): + try: + data['heights'] = json.loads(data['heights']) + except (json.JSONDecodeError, TypeError): + data['heights'] = None + + return data + + class LabelbaseSerializer(serializers.ModelSerializer): def __init__(self, *args, **kwargs): super(LabelbaseSerializer, self).__init__(*args, **kwargs) diff --git a/django/labellabor/urls.py b/django/labellabor/urls.py index 286978e..0bc5db6 100644 --- a/django/labellabor/urls.py +++ b/django/labellabor/urls.py @@ -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//currency-sync/', + CurrencySyncView.as_view(), + name='currency_sync' + ), + path( + 'labelbase//currency-sync/action/', + CurrencySyncActionView.as_view(), + name='currency_sync_action' + ), + path( + 'labelbase//fill-missing-data/', + FillMissingDataView.as_view(), + name='fill_missing_data' + ), + path( + 'labelbase//fill-missing-data/action/', + FillMissingDataActionView.as_view(), + name='fill_missing_data_action' + ), + #path( # "labelbase//portfolio/", # login_required(LabelbasePortfolioView.as_view()), diff --git a/django/labellabor/views.py b/django/labellabor/views.py index 42c1b22..deab468 100644 --- a/django/labellabor/views.py +++ b/django/labellabor/views.py @@ -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 diff --git a/django/requirements.txt b/django/requirements.txt index f622342..d6e115f 100644 --- a/django/requirements.txt +++ b/django/requirements.txt @@ -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 diff --git a/django/templates/_base.html b/django/templates/_base.html index 46f846d..a07b2fc 100644 --- a/django/templates/_base.html +++ b/django/templates/_base.html @@ -184,11 +184,12 @@ {% endif %} {% endwith %}
  • Fix & Manage
  • +
  • Sync Fields
  • {% comment %}
  • UTXOs Health
  • {% endcomment %}
  • Tree Map
  • {% comment %}
  • Stats & KPIs
  • {% endcomment %} {% if request.user.profile.use_fiatfinances %} -
  • Fiat Finances
  • +
  • Fiat Finances
  • {% endif %}
  • Import
  • Export
  • diff --git a/django/templates/home.html b/django/templates/home.html index c1731e7..178e505 100644 --- a/django/templates/home.html +++ b/django/templates/home.html @@ -304,7 +304,34 @@ +
    +
    +

    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.

    +
    +
    + +
    +
    +

    Auto-Fill Missing Data

    +

    Automatically populate transaction metadata from OutputStat records. Fill block heights, timestamps, and values for your outputs with one click, saving time and ensuring accuracy.

    +
    +
    + +
    +
    +

    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.

    +
    +
    +



    diff --git a/django/templates/label_edit.html b/django/templates/label_edit.html index 8a6e060..5830f6f 100644 --- a/django/templates/label_edit.html +++ b/django/templates/label_edit.html @@ -5,43 +5,12 @@ {% load backgroundtask_tags %} {% load attachments_tags %} -{% block title %}{{ object.type }} {{ object.ref }}{% endblock %} - - -{% comment %}{% endcomment %} +{% block title %}{{ object.type }} {{ object.ref }}{% endblock %} {% block content %} {% get_attachments_for object.get_label_attachment as my_attachments %} -
    {% endif %} -{% comment %} -{% is_label_id_in_queue object.id as is_in_queue %} - {% if is_in_queue %} -
    - - Output in queue! This output is currently in the processing queue. It will be checked shortly. -
    -{% else %} - {% if object.type == "output" %} - {% switch output.get_spent_status %} - {% case "spent" %} -
    - Output spent! Blockchain records indicate that this output has been spent in another transaction. -
    - {% case "unspent" %} -
    - Output unspent! Blockchain records indicate that this output has not been spent yet. -
    - {% case "unconfirmed" %} -
    -
    - - -
    - Output unconfirmed! Blockchain records indicate that this output has not been confirmed yet. -
    - {% endswitch %} - {% endif %} -{% endif %} -{% endcomment %} - {% is_label_id_in_queue object.id as is_in_queue %} {% if is_in_queue %}
    @@ -204,32 +128,11 @@ {% endif %} {% endif %} - - -{% comment %} -{% if object.type == "addr" %} -
    - Heads up! There are multiple transaction outputs sent to this address. For privacy reasons, do not reuse addresses. -
    -{% endif %} -{% endcomment %} - {% block label_edit_content %} {% endblock %} - - - - -
    - - - - +
    + +// 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/labelbase.html b/django/templates/labelbase.html index 5b4ba2e..d893b5b 100644 --- a/django/templates/labelbase.html +++ b/django/templates/labelbase.html @@ -23,6 +23,31 @@
    {% if label_list %} + + +
    @@ -32,6 +57,8 @@ + + @@ -123,6 +150,7 @@
    label origin spendablehealth