v2.1.0 w/ attachments support

This commit is contained in:
Xavier Fiechter 2024-04-14 01:59:27 +02:00
parent 3547f4b4b3
commit 113cb6e929
92 changed files with 2268 additions and 82 deletions

View file

@ -7,6 +7,7 @@ db/
*.pyc
__pycache__
django/importer/uploadeddata/*
django/attachments/attachment/*
_scratches
exports.sh
.last_git_commit

1
.gitignore vendored
View file

@ -8,6 +8,7 @@ db/
*.pyc
__pycache__
django/importer/uploadeddata/*
django/attachments/attachment/*
#_scratches
exports.sh
.last_git_commit

View file

@ -0,0 +1,3 @@
Friendly-forked at commit https://github.com/bartTC/django-attachments/commit/d0c3e7b366691f2a57329c804b0dd002064396a6 (version 1.11) and extended for Labelbase.
BSD 3-Clause "New" or "Revised" License https://github.com/bartTC/django-attachments/blob/master/LICENSE

View file

@ -0,0 +1 @@
default_app_config = "attachments.apps.AttachmentsConfig"

View file

@ -0,0 +1,21 @@
from __future__ import unicode_literals
from django.contrib.contenttypes.admin import GenericStackedInline
from django.contrib import admin
from .models import LabelAttachment
from .models import Attachment
class AttachmentInlines(GenericStackedInline):
model = Attachment
exclude = ()
extra = 1
admin.site.register(LabelAttachment)

View file

@ -0,0 +1,9 @@
from __future__ import unicode_literals
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class AttachmentsConfig(AppConfig):
default_auto_field = 'django.db.models.AutoField'
name = "attachments"
verbose_name = _("Attachments")

Binary file not shown.

View file

@ -0,0 +1,46 @@
from __future__ import unicode_literals
from django import forms
from django.apps import apps
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.template.defaultfilters import filesizeformat
from django.utils.translation import gettext_lazy as _
from .models import Attachment
config = apps.get_app_config("attachments")
def validate_max_size(data):
if (
hasattr(settings, "FILE_UPLOAD_MAX_SIZE")
and data.size > settings.FILE_UPLOAD_MAX_SIZE
):
raise forms.ValidationError(
_("File exceeds maximum size of {size}").format(
size=filesizeformat(settings.FILE_UPLOAD_MAX_SIZE)
)
)
def custom_attachment_validators(uploaded_file):
for validator in getattr(config, "attachment_validators", ()):
validator(uploaded_file)
class AttachmentForm(forms.ModelForm):
attachment_file = forms.FileField(
label=_("Upload attachment"),
validators=[validate_max_size, custom_attachment_validators]
)
class Meta:
model = Attachment
fields = ("attachment_file",)
def save(self, request, obj, *args, **kwargs):
self.instance.creator = request.user
self.instance.content_type = ContentType.objects.get_for_model(obj)
self.instance.object_id = obj.pk
super(AttachmentForm, self).save(*args, **kwargs)

View file

@ -0,0 +1,11 @@
Transifex.net Token Verification
=================================
The list of tokens bellow guarantee the respective users to be able to enable
submission on components using the following repository url:
git://github.com/bartTC/django-attachments.git
Tokens:
24u9WhbEZQbCGDn3ruWJ6c7YpDpSxCCf / bartTC

Binary file not shown.

View file

@ -0,0 +1,54 @@
# django-attachments in Danish
# django-attachments på Dansk
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# Michael Lind Mortensen <illio@cs.au.dk>, 2009.
#
msgid ""
msgstr ""
"Project-Id-Version: django-attachments\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2009-08-27 23:52+0200\n"
"PO-Revision-Date: 2010-04-04 00:24+0100\n"
"Last-Translator: Martin Mahner <martin@mahner.org>\n"
"Language-Team: da <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: forms.py:7
msgid "Upload attachment"
msgstr "Upload vedhæftning"
#: models.py:29
msgid "creator"
msgstr "skaber"
#: models.py:30
msgid "attachment"
msgstr "vedhæftning"
#: models.py:31
msgid "created"
msgstr "skabt"
#: models.py:32
msgid "modified"
msgstr "ændret"
#: views.py:33
msgid "Your attachment was uploaded."
msgstr "Din vedhæftning blev uploadet."
#: views.py:51
msgid "Your attachment was deleted."
msgstr "Din vedhæftning blev slettet."
#: templates/attachments/add_form.html:7
msgid "Add attachment"
msgstr "Tilføj vedhæftning"
#: templates/attachments/delete_link.html:2
msgid "Delete attachment"
msgstr "Slet vedhæftning"

Binary file not shown.

View file

@ -0,0 +1,53 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: django-attachments\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2010-04-04 00:19+0200\n"
"PO-Revision-Date: 2010-04-04 00:29+0100\n"
"Last-Translator: Martin Mahner <martin@mahner.org>\n"
"Language-Team: de <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: forms.py:7
msgid "Upload attachment"
msgstr "Anhang hochladen"
#: models.py:29
msgid "creator"
msgstr "Autor"
#: models.py:30
msgid "attachment"
msgstr "Anhang"
#: models.py:31
msgid "created"
msgstr "Erstellt"
#: models.py:32
msgid "modified"
msgstr "Geändert"
#: views.py:33
msgid "Your attachment was uploaded."
msgstr "Dein Anhang wurde hochgeladen."
#: views.py:51
msgid "Your attachment was deleted."
msgstr "Dein Anhang wurde gelöscht."
#: templates/attachments/add_form.html:7
msgid "Add attachment"
msgstr "Anhang hinzufügen"
#: templates/attachments/delete_link.html:2
msgid "Delete attachment"
msgstr "Anhang löschen"

Binary file not shown.

View file

@ -0,0 +1,53 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# Panos Laganakos <panos.laganakos@gmail.com>, 2010.
#
msgid ""
msgstr ""
"Project-Id-Version: django-attachments\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2010-04-04 02:52+0300\n"
"PO-Revision-Date: 2010-04-04 15:07+0100\n"
"Last-Translator: Martin Mahner <martin@mahner.org>\n"
"Language-Team: gr <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: forms.py:7
msgid "Upload attachment"
msgstr "Φώρτωση συνημμένου"
#: models.py:29
msgid "creator"
msgstr "δημιουργός"
#: models.py:30
msgid "attachment"
msgstr "συνημμένο"
#: models.py:31
msgid "created"
msgstr "δημιουργήθηκε"
#: models.py:32
msgid "modified"
msgstr "τροποποιήθηκε"
#: views.py:33
msgid "Your attachment was uploaded."
msgstr "Η επισύναψη σας ολοκληρώθηκε."
#: views.py:51
msgid "Your attachment was deleted."
msgstr "Το συνήμμενο σας διεγράφει."
#: templates/attachments/add_form.html:7
msgid "Add attachment"
msgstr "Προσθέστε ένα συνημμένο"
#: templates/attachments/delete_link.html:2
msgid "Delete attachment"
msgstr "Διαγράψτε το συνημμένο"

Binary file not shown.

View file

@ -0,0 +1,53 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: django-attachments\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2010-04-04 00:18+0200\n"
"PO-Revision-Date: 2010-04-04 00:28+0100\n"
"Last-Translator: Martin Mahner <martin@mahner.org>\n"
"Language-Team: en <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: forms.py:7
msgid "Upload attachment"
msgstr "Upload attachment"
#: models.py:29
msgid "creator"
msgstr "creator"
#: models.py:30
msgid "attachment"
msgstr "attachment"
#: models.py:31
msgid "created"
msgstr "created"
#: models.py:32
msgid "modified"
msgstr "modified"
#: views.py:33
msgid "Your attachment was uploaded."
msgstr "Your attachment was uploaded."
#: views.py:51
msgid "Your attachment was deleted."
msgstr "Your attachment was deleted."
#: templates/attachments/add_form.html:7
msgid "Add attachment"
msgstr "Add attachment"
#: templates/attachments/delete_link.html:2
msgid "Delete attachment"
msgstr "Delete attachment"

Binary file not shown.

View file

@ -0,0 +1,54 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# Gonzalo Bustos, 2015.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2014-05-31 17:20-0300\n"
"PO-Revision-Date: 2015-10-11 22:04-0300\n"
"Last-Translator: Gonzalo Bustos\n"
"Language-Team: Spanish (Argentina)\n"
"Language: es_AR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 1.6.10\n"
#: forms.py:7
msgid "Upload attachment"
msgstr "Subir adjunto"
#: models.py:32
msgid "creator"
msgstr "creador"
#: models.py:33 models.py:38
msgid "attachment"
msgstr "adjunto"
#: models.py:34
msgid "created"
msgstr "creado"
#: models.py:35
msgid "modified"
msgstr "modificado"
#: views.py:34
msgid "Your attachment was uploaded."
msgstr "Su adjunto fue subido."
#: views.py:52
msgid "Your attachment was deleted."
msgstr "Su adjunto fue eliminado."
#: templates/attachments/add_form.html:7
msgid "Add attachment"
msgstr "Agregar adjunto"
#: templates/attachments/delete_link.html:2
msgid "Delete attachment"
msgstr "Eliminar adjunto"

Binary file not shown.

View file

@ -0,0 +1,54 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: django-attachments\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-06-11 15:55+0300\n"
"PO-Revision-Date: 2016-06-11 16:03+0300\n"
"Last-Translator: Aleksi Häkli <aleksi.hakli@iki.fi>\n"
"Language-Team: fi <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fi\n"
"X-Generator: Poedit 1.8.8\n"
#: forms.py:7
msgid "Upload attachment"
msgstr "Lähetä liite"
#: models.py:29
msgid "creator"
msgstr "luoja"
#: models.py:30
msgid "attachment"
msgstr "liite"
#: models.py:31
msgid "created"
msgstr "luotu"
#: models.py:32
msgid "modified"
msgstr "muokattu"
#: views.py:33
msgid "Your attachment was uploaded."
msgstr "Liitteesi ladattiin palvelimelle."
#: views.py:51
msgid "Your attachment was deleted."
msgstr "Liitteesi poistettiin palvelimelta."
#: templates/attachments/add_form.html:7
msgid "Add attachment"
msgstr "Lisää liite"
#: templates/attachments/delete_link.html:2
msgid "Delete attachment"
msgstr "Poista liite"

Binary file not shown.

View file

@ -0,0 +1,53 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: django-attachments\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2010-04-04 00:18+0200\n"
"PO-Revision-Date: 2010-04-04 00:28+0100\n"
"Last-Translator: AERT <dev.aert@gmail.com>\n"
"Language-Team: en <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: forms.py:7
msgid "Upload attachment"
msgstr "Ajouter une pièce jointe"
#: models.py:29
msgid "creator"
msgstr "créateur"
#: models.py:30
msgid "attachment"
msgstr "pièce jointe"
#: models.py:31
msgid "created"
msgstr "créé"
#: models.py:32
msgid "modified"
msgstr "modifié"
#: views.py:33
msgid "Your attachment was uploaded."
msgstr "Votre pièce jointe a été ajoutée."
#: views.py:51
msgid "Your attachment was deleted."
msgstr "Votre pièce jointe a été supprimée."
#: templates/attachments/add_form.html:7
msgid "Add attachment"
msgstr "Ajouter une pièce jointe"
#: templates/attachments/delete_link.html:2
msgid "Delete attachment"
msgstr "Supprimer la pièce jointe"

Binary file not shown.

View file

@ -0,0 +1,77 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-05-29 16:35+0200\n"
"PO-Revision-Date: 2018-05-29 16:39+0200\n"
"Language: it\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"Last-Translator: Mario Orlandi <morlandi@brainstorm.it>\n"
"Language-Team: \n"
"X-Generator: Poedit 2.0.7\n"
#: apps.py:7
msgid "Attachments"
msgstr "Allegati"
#: forms.py:13
#, python-brace-format
msgid "File exceeds maximum size of {size}"
msgstr "Il file supera le dimensioni massime di {size}"
#: forms.py:18
msgid "Upload attachment"
msgstr "Invia allegato"
#: models.py:35
msgid "creator"
msgstr "autore"
#: models.py:36 models.py:41
msgid "attachment"
msgstr "allegato"
#: models.py:37
msgid "created"
msgstr "creato il"
#: models.py:38
msgid "modified"
msgstr "modificato il"
#: models.py:42
msgid "attachments"
msgstr "allegati"
#: models.py:45
msgid "Can delete foreign attachments"
msgstr "Può eliminare gli allegati esterni"
#: models.py:49
#, python-brace-format
msgid "{username} attached {filename}"
msgstr "{username} ha allegato {filename}"
#: templates/attachments/add_form.html:8
msgid "Add attachment"
msgstr "Aggiungi allegato"
#: templates/attachments/delete_link.html:2
msgid "Delete attachment"
msgstr "Elimina allegato"
#: views.py:51
msgid "Your attachment was uploaded."
msgstr "L'allegato è stato inviato."
#: views.py:75
msgid "Your attachment was deleted."
msgstr "L'allegato è stato eliminato."

Binary file not shown.

View file

@ -0,0 +1,54 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2014-05-31 17:20-0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: forms.py:7
msgid "Upload attachment"
msgstr "Subir anexo"
#: models.py:32
msgid "creator"
msgstr "criador"
#: models.py:33 models.py:38
msgid "attachment"
msgstr "anexo"
#: models.py:34
msgid "created"
msgstr "criado"
#: models.py:35
msgid "modified"
msgstr "modificado"
#: views.py:34
msgid "Your attachment was uploaded."
msgstr "Seu anexo foi enviado."
#: views.py:52
msgid "Your attachment was deleted."
msgstr "Seu anexo foi excluido."
#: templates/attachments/add_form.html:7
msgid "Add attachment"
msgstr "Adicionar anexo"
#: templates/attachments/delete_link.html:2
msgid "Delete attachment"
msgstr "Excluir anexo"

View file

@ -0,0 +1,61 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: django-attachments\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2017-04-07 23:35+0300\n"
"PO-Revision-Date: 2017-04-07 23:26+0300:MI+ZONE\n"
"Last-Translator: Maxim Barabanov <maxim.barabanov@gmail.com>\n"
"Language-Team: RU <LL@li.org>\n"
"Language: Russian\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
"%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n"
"%100>=11 && n%100<=14)? 2 : 3);\n"
#: .\attachments\forms.py:9
msgid "Upload attachment"
msgstr "Загрузить вложение"
#: .\attachments\models.py:35
msgid "creator"
msgstr "создатель"
#: .\attachments\models.py:36
msgid "attachment"
msgstr "вложение"
#: .\attachments\models.py:37
msgid "created"
msgstr "создано"
#: .\attachments\models.py:38
msgid "modified"
msgstr "изменено"
#: .\attachments\models.py:43
msgid "Can delete foreign attachments"
msgstr "Может удалять чужие вложения"
#: .\attachments\templates\attachments\add_form.html:8
msgid "Add attachment"
msgstr "Добавить вложение"
#: .\attachments\templates\attachments\delete_link.html:2
msgid "Delete attachment"
msgstr "Удалить вложение"
#: .\attachments\views.py:40
msgid "Your attachment was uploaded."
msgstr "Ваше вложение было загружено."
#: .\attachments\views.py:62
msgid "Your attachment was deleted."
msgstr "Ваше вложение было удалено"

View file

@ -0,0 +1,44 @@
from django.core.management.base import BaseCommand
from attachments.models import Attachment
from attachments.views import remove_file_from_disk
class Command(BaseCommand):
help = ("Remove attachments for which the related objects "
"don't exist anymore!")
def add_arguments(self, parser):
parser.add_argument(
'-y', '--yes', default='x', action='store_const', const='y',
dest='answer', help='Automatically confirm deletion',
)
def handle(self, *args, **kwargs):
verbose = kwargs['verbosity'] >= 1
answer = kwargs['answer']
# -v0 sets --yes
if not verbose:
answer = 'y'
for att in Attachment.objects.all():
if att.content_object is None:
if verbose:
self.stdout.write(
"Attachment `%s' to non-existing `%s' with PK `%s'" %
(att, att.content_type.model, att.object_id))
while answer not in 'yn':
answer = input("Do you wish to delete? [yN] ")
if not answer:
answer = 'x'
continue
answer = answer[0].lower()
if answer == 'y' :
remove_file_from_disk(att.attachment_file)
att.delete()
if verbose:
self.stdout.write("Deleted attachment `%s'" % att)

View file

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
from django.conf import settings
import attachments.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('contenttypes', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Attachment',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('object_id', models.PositiveIntegerField()),
('attachment_file', models.FileField(upload_to=attachments.models.attachment_upload, verbose_name='attachment')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='created')),
('modified', models.DateTimeField(auto_now=True, verbose_name='modified')),
('content_type', models.ForeignKey(to='contenttypes.ContentType', on_delete=models.CASCADE)),
('creator', models.ForeignKey(related_name='created_attachments', verbose_name='creator', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)),
],
options={
'ordering': ['-created'],
'permissions': (('delete_foreign_attachments', 'Can delete foreign attachments'),),
},
bases=(models.Model,),
),
]

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-01-04 12:47
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('attachments', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='attachment',
options={'ordering': ['-created'], 'permissions': (('delete_foreign_attachments', 'Can delete foreign attachments'),), 'verbose_name': 'attachment', 'verbose_name_plural': 'attachments'},
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 2.2.3 on 2019-07-22 12:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('attachments', '0002_auto_20180104_1247'),
]
operations = [
migrations.AlterField(
model_name='attachment',
name='object_id',
field=models.TextField(),
),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 3.0.9 on 2020-08-17 13:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('attachments', '0003_auto_20190722_1216'),
]
operations = [
migrations.AlterField(
model_name='attachment',
name='created',
field=models.DateTimeField(auto_now_add=True, db_index=True,
verbose_name='created'),
),
migrations.AlterField(
model_name='attachment',
name='modified',
field=models.DateTimeField(auto_now=True, db_index=True,
verbose_name='modified'),
),
]

View file

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('attachments', '0004_db_index'),
]
operations = [
migrations.AlterField(
model_name='attachment',
name='object_id',
field=models.CharField(db_index=True, max_length=64),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.2.25 on 2024-04-11 14:35
from django.db import migrations, models
import uuid_upload_path.storage
class Migration(migrations.Migration):
dependencies = [
('attachments', '0005_object_id_charfield'),
]
operations = [
migrations.AlterField(
model_name='attachment',
name='attachment_file',
field=models.FileField(upload_to=uuid_upload_path.storage.upload_to, verbose_name='attachment'),
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 3.2.25 on 2024-04-11 15:06
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('attachments', '0006_alter_attachment_attachment_file'),
]
operations = [
migrations.CreateModel(
name='LabelAttachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('type_ref_hash', models.CharField(blank=True, help_text='Reflects type + ref, where type could be any bip-329 defined type', max_length=64)),
('network', models.CharField(choices=[('mainnet', 'Mainnet'), ('testnet', 'Testnet')], default='mainnet', help_text="Choose the network for this labelbase's label attachement.", max_length=10)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'network', 'type_ref_hash')},
},
),
]

View file

@ -0,0 +1,117 @@
from __future__ import unicode_literals
import os
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.translation import gettext_lazy as _
from six import python_2_unicode_compatible
from uuid_upload_path import upload_to
from django.contrib.auth.models import User
import logging
logger = logging.getLogger('labelbase')
def attachment_upload(instance, filename):
"""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)
@python_2_unicode_compatible
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")
creator = models.ForeignKey(
settings.AUTH_USER_MODEL,
related_name="created_attachments",
verbose_name=_("creator"),
on_delete=models.CASCADE,
)
attachment_file = models.FileField(
_("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)
class Meta:
verbose_name = _("attachment")
verbose_name_plural = _("attachments")
ordering = ["-created"]
permissions = (
("delete_foreign_attachments", _("Can delete foreign attachments")),
)
def __str__(self):
return _("{username} attached {filename}").format(
username=self.creator.get_username(),
filename=self.attachment_file.name,
)
@property
def filename(self):
return os.path.split(self.attachment_file.name)[1]
def attach_to(self, new_object, update_path=False):
"""
Attach to a new object and possibly move the actual file on disk!
.. important::
As long as path names are valid you can continue serving
the files from their original path and not change it!
"""
self.object_id = new_object.pk
self.content_type = ContentType.objects.get_for_model(new_object)
self.save()
if update_path:
old_path = self.attachment_file.path
self.attachment_file.name = upload_to # attachment_upload(self, self.filename)
self.attachment_file.save()
os.makedirs(
os.path.dirname(self.attachment_file.path), exist_ok=True)
os.rename(old_path, self.attachment_file.path)
class LabelAttachment(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True)
type_ref_hash = models.CharField(max_length=64,
blank=True,
help_text="Reflects type + ref, where type could be any bip-329 defined type")
MAINNET = 'mainnet'
TESTNET = 'testnet'
NETWORK_CHOICES = [
(MAINNET, 'Mainnet'),
(TESTNET, 'Testnet'),
]
network = models.CharField(
max_length=10,
choices=NETWORK_CHOICES,
default='mainnet',
help_text="Choose the network for this labelbase's label attachement."
)
class Meta:
unique_together = (("user", "network", "type_ref_hash"),)

View file

@ -0,0 +1,77 @@
from django.template import Library
from django.urls import reverse
from ..forms import AttachmentForm
from ..models import Attachment
from ..views import add_url_for_obj
register = Library()
@register.inclusion_tag("attachments/add_form.html", takes_context=True)
def attachment_form(context, obj, **kwargs):
"""
Renders a "upload attachment" form.
The user must own ``attachments.add_attachment permission`` to add
attachments.
"""
if context["user"].has_perm("attachments.add_attachment"):
return {
"form": AttachmentForm(),
"form_url": add_url_for_obj(obj),
"next": kwargs.get("next", context.request.build_absolute_uri()),
}
else:
return {"form": None}
@register.inclusion_tag("attachments/delete_link.html", takes_context=True)
def attachment_delete_link(context, attachment, **kwargs):
"""
Renders a html link to the delete view of the given attachment. Returns
no content if the request-user has no permission to delete attachments.
The user must own either the ``attachments.delete_attachment`` permission
and is the creator of the attachment, that he can delete it or he has
``attachments.delete_foreign_attachments`` which allows him to delete all
attachments.
"""
if context["user"].has_perm("attachments.delete_foreign_attachments") or (
context["user"] == attachment.creator
and context["user"].has_perm("attachments.delete_attachment")
):
return {
"next": kwargs.get("next", context.request.build_absolute_uri()),
"delete_url": reverse(
"attachments:delete", kwargs={"attachment_pk": attachment.pk}
),
}
return {"delete_url": None}
@register.simple_tag
def attachments_count(obj):
"""
Counts attachments that are attached to a given object::
{% attachments_count obj %}
"""
attachments_count = Attachment.objects.attachments_for_object(obj).count()
print("obj: {}, attachments_count: {}".format(obj.id, attachments_count))
return attachments_count
@register.simple_tag
def get_attachments_for(obj, *args, **kwargs):
"""
Resolves attachments that are attached to a given object. You can specify
the variable name in the context the attachments are stored using the `as`
argument.
Syntax::
{% get_attachments_for obj as "my_attachments" %}
"""
return Attachment.objects.attachments_for_object(obj)

View file

View file

@ -0,0 +1,68 @@
# -*- encoding: utf-8 -*-
from __future__ import unicode_literals
from django.contrib.auth.models import Permission, User
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.urls import reverse
from ..models import Attachment
from .testapp.models import TestModel
class BaseTestCase(TestCase):
target_model_class = TestModel
def setUp(self):
"""
Create two users with `attachments.add_attachment` permission
and one object to attach files to.
"""
content_type = ContentType.objects.get_for_model(Attachment)
self.add_permission = Permission.objects.get(
content_type=content_type, codename="add_attachment"
)
self.del_permission = Permission.objects.get(
content_type=content_type, codename="delete_attachment"
)
self.del_foreign_permission = Permission.objects.get(
content_type=content_type, codename="delete_foreign_attachments"
)
self.cred_jon = {"username": "jon", "password": "foobar"}
self.cred_jane = {"username": "jane", "password": "foobar"}
self.jon = User.objects.create_user(**self.cred_jon)
self.jon.user_permissions.add(self.add_permission)
self.jon.user_permissions.add(self.del_permission)
self.jane = User.objects.create_user(**self.cred_jane)
self.jane.user_permissions.add(self.add_permission)
self.jane.user_permissions.add(self.del_permission)
self.obj = self.target_model_class.objects.create(title="My first test item")
def _upload_testfile(self, file_obj=None, file_content=b"file content", **extra):
"""
Uploads a sample file for the given user.
"""
add_url = reverse(
"attachments:add",
kwargs={
"app_label": "testapp",
"model_name": self.target_model_class.__name__.lower(),
"pk": self.obj.pk,
},
)
if not file_obj:
file_obj = SimpleUploadedFile(
"Ünicode Filename 🙂.jpg",
file_content,
content_type="image/jpeg",
)
return self.client.post(
add_url, {"attachment_file": file_obj}, follow=True,
**extra
)

View file

@ -0,0 +1,19 @@
from django.core.management import call_command
from django.test import TestCase
from six import StringIO
class IntegrityTestCase(TestCase):
"""
Very basic tests around the app itself, not the code.
"""
def test_no_pending_migrations(self):
"""
Make sure all model changes are reflected with Django migrations.
"""
output = StringIO()
call_command(
"makemigrations", "--dry-run", interactive=False, stdout=output
)
self.assertTrue("No changes detected" in output.getvalue())

View file

@ -0,0 +1,69 @@
from django.urls import reverse
from ..models import Attachment
from .base import BaseTestCase
class ViewTestCase(BaseTestCase):
def setUp(self):
super(ViewTestCase, self).setUp()
self.item_url = reverse("testapp-detail", kwargs={"pk": self.obj.pk})
def test_uploaded_attachment_urls_are_listed(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
response = self.client.get(self.item_url)
attachment = Attachment.objects.attachments_for_object(self.obj)[0]
self.assertTrue(attachment.attachment_file.url in str(response.content))
def test_attachment_count_is_listed(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
self._upload_testfile()
response = self.client.get(self.item_url)
attachment_count = Attachment.objects.attachments_for_object(
self.obj
).count()
self.assertTrue(
"Object has %d attachments" % attachment_count
in str(response.content)
)
def test_upload_form_is_listed_with_add_permission(self):
self.client.login(**self.cred_jon)
response = self.client.get(self.item_url)
self.assertTrue("<form" in str(response.content))
def test_upload_form_is_not_listed_without_add_permission(self):
self.jon.user_permissions.remove(self.add_permission)
self.client.login(**self.cred_jon)
response = self.client.get(self.item_url)
self.assertFalse("<form" in str(response.content))
def test_delete_link_is_listed_with_delete_permission(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
response = self.client.get(self.item_url)
self.assertTrue("delete-attachment" in str(response.content))
def test_delete_link_is_not_listed_without_delete_permission(self):
self.jon.user_permissions.remove(self.del_permission)
self.client.login(**self.cred_jon)
self._upload_testfile()
response = self.client.get(self.item_url)
self.assertFalse("delete-attachment" in str(response.content))
def test_delete_link_is_listed_with_foreign_delete_permission(self):
self.jon.user_permissions.add(self.del_foreign_permission)
self.client.login(**self.cred_jane)
self._upload_testfile()
self.client.login(**self.cred_jon)
response = self.client.get(self.item_url)
self.assertTrue("delete-attachment" in str(response.content))
def test_delete_link_is_not_listed_for_others_attachments(self):
self.client.login(**self.cred_jane)
self._upload_testfile()
self.client.login(**self.cred_jon)
response = self.client.get(self.item_url)
self.assertFalse("delete-attachment" in str(response.content))

View file

@ -0,0 +1,273 @@
import os
import json
import mock
from http import HTTPStatus
from django.urls import reverse
from ..models import Attachment
from .base import BaseTestCase
from .testapp.models import ModelWithUuidPk
class ViewTestCase(BaseTestCase):
def test_empty_post_to_form_wont_create_attachment(self):
add_url = reverse(
"attachments:add",
kwargs={
"app_label": "testapp",
"model_name": "testmodel",
"pk": self.obj.pk,
},
)
response = self.client.post(add_url)
self.assertEqual(302, response.status_code)
self.assertEqual(Attachment.objects.count(), 0)
self.assertEqual(
Attachment.objects.attachments_for_object(self.obj).count(), 0
)
def test_invalid_model_wont_fail(self):
add_url = reverse(
"attachments:add",
kwargs={
"app_label": "thisdoes",
"model_name": "notexist",
"pk": self.obj.pk,
},
)
response = self.client.post(add_url)
self.assertEqual(302, response.status_code)
self.assertEqual(Attachment.objects.count(), 0)
self.assertEqual(
Attachment.objects.attachments_for_object(self.obj).count(), 0
)
def test_invalid_attachment_wont_fail(self):
self.client.login(**self.cred_jon)
self._upload_testfile(file_obj="Not a UploadedFile object")
self.assertEqual(Attachment.objects.count(), 0)
self.assertEqual(
Attachment.objects.attachments_for_object(self.obj).count(), 0
)
def test_upload_size_less_than_limit(self):
# NOTE: in all of the other tests there's no limit specified
# so they will cover the branch where the setting is missing
with self.settings(FILE_UPLOAD_MAX_SIZE=1024):
self.client.login(**self.cred_jon)
self._upload_testfile()
self.assertEqual(Attachment.objects.count(), 1)
self.assertEqual(
Attachment.objects.attachments_for_object(self.obj).count(), 1
)
def test_upload_size_more_than_limit(self):
# we set a limit of 1 byte b/c the file used for testing
# is very small
with self.settings(FILE_UPLOAD_MAX_SIZE=1):
self.client.login(**self.cred_jon)
response = self._upload_testfile()
self.assertContains(response, "File exceeds maximum size of 1")
self.assertEqual(Attachment.objects.count(), 0)
self.assertEqual(
Attachment.objects.attachments_for_object(self.obj).count(), 0
)
def test_upload_without_permission(self):
"""
Remove the 'add permission' and try to upload a file.
"""
self.jon.user_permissions.remove(self.add_permission)
self.client.login(**self.cred_jon)
self._upload_testfile()
self.assertEqual(Attachment.objects.count(), 0)
self.assertEqual(
Attachment.objects.attachments_for_object(self.obj).count(), 0
)
def test_upload_with_permission(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
self.assertEqual(Attachment.objects.count(), 1)
self.assertEqual(
Attachment.objects.attachments_for_object(self.obj).count(), 1
)
def test_unauthed_user_cant_delete_attachment(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
att = Attachment.objects.first()
del_url = reverse(
"attachments:delete", kwargs={"attachment_pk": att.pk}
)
self.client.logout()
self.client.get(del_url, follow=True)
self.assertEqual(Attachment.objects.count(), 1)
self.assertEqual(
Attachment.objects.attachments_for_object(self.obj).count(), 1
)
def test_author_can_delete_attachment(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
att = Attachment.objects.first()
file_path = att.attachment_file.path
del_url = reverse(
"attachments:delete", kwargs={"attachment_pk": att.pk}
)
self.client.get(del_url, follow=True)
self.assertEqual(Attachment.objects.count(), 0)
# file on disk is still present b/c setting not specified
self.assertTrue(os.path.exists(file_path))
def test_author_cant_delete_attachment_if_no_delete_permission(self):
self.jon.user_permissions.remove(self.del_permission)
self.client.login(**self.cred_jon)
self._upload_testfile()
att = Attachment.objects.first()
del_url = reverse(
"attachments:delete", kwargs={"attachment_pk": att.pk}
)
self.client.get(del_url, follow=True)
self.assertEqual(Attachment.objects.count(), 1)
def test_author_cant_delete_others_attachment(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
obj1 = Attachment.objects.order_by("-created")[0]
self.client.login(**self.cred_jane)
self._upload_testfile()
obj2 = Attachment.objects.order_by("-created")[0]
self.assertNotEqual(obj1, obj2)
# Jon can't delete Janes attachment
self.client.login(**self.cred_jon)
del_url = reverse(
"attachments:delete", kwargs={"attachment_pk": obj2.pk}
)
self.client.get(del_url, follow=True)
self.assertEqual(Attachment.objects.count(), 2)
def test_author_can_delete_others_attachment_with_permission(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
obj1 = Attachment.objects.order_by("-created")[0]
path1 = obj1.attachment_file.path
self.client.login(**self.cred_jane)
self._upload_testfile()
obj2 = Attachment.objects.order_by("-created")[0]
path2 = obj2.attachment_file.path
self.assertNotEqual(obj1, obj2)
# Jon has the `delete_foreign_attachments` permission so he can
# delete Janes attachment
self.jon.user_permissions.add(self.del_foreign_permission)
self.client.login(**self.cred_jon)
# explicitly set the delete setting to False to
# cover that branch as well
with self.settings(DELETE_ATTACHMENT_FILE=False):
del_url = reverse(
"attachments:delete", kwargs={"attachment_pk": obj2.pk}
)
self.client.get(del_url, follow=True)
self.assertEqual(Attachment.objects.count(), 1)
self.assertTrue(os.path.exists(path1))
self.assertTrue(os.path.exists(path2))
def test_delete_removes_files_from_disk_if_settings(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
att = Attachment.objects.first()
file_path = att.attachment_file.path
with self.settings(DELETE_ATTACHMENTS_FROM_DISK=True):
del_url = reverse(
"attachments:delete", kwargs={"attachment_pk": att.pk}
)
self.client.get(del_url, follow=True)
self.assertEqual(Attachment.objects.count(), 0)
self.assertFalse(os.path.exists(file_path))
def test_delete_does_not_raise_if_settings_and_file_missing(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
att = Attachment.objects.first()
file_path = att.attachment_file.path
# remove the file before hand
os.remove(file_path)
with self.settings(DELETE_ATTACHMENTS_FROM_DISK=True):
del_url = reverse(
"attachments:delete", kwargs={"attachment_pk": att.pk}
)
self.client.get(del_url, follow=True)
self.assertEqual(Attachment.objects.count(), 0)
self.assertFalse(os.path.exists(file_path))
def test_delete_does_not_raise_if_os_remove_raises(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
att = Attachment.objects.first()
with mock.patch("attachments.views.os.remove") as _mock:
_mock.side_effect = OSError("Test file does not exist")
with self.settings(DELETE_ATTACHMENTS_FROM_DISK=True):
del_url = reverse(
"attachments:delete", kwargs={"attachment_pk": att.pk}
)
self.client.get(del_url, follow=True)
self.assertEqual(Attachment.objects.count(), 0)
# NOTE: we don't assert the file path here because
# the mock which raises will not actually delete it
class UUIDTestCase(BaseTestCase):
target_model_class = ModelWithUuidPk
def test_upload_with_permission(self):
self.client.login(**self.cred_jon)
self._upload_testfile()
self.assertEqual(Attachment.objects.count(), 1)
self.assertEqual(
Attachment.objects.attachments_for_object(self.obj).count(), 1
)
class CustomValidatorsTestCase(BaseTestCase):
def test_deny_specific_content(self):
self.client.login(**self.cred_jon)
response = self._upload_testfile(file_content=b"<xml>this is not allowed</xml>")
self.assertContains(response, "XML is forbidden")
self.assertEqual(Attachment.objects.count(), 0)
self.assertEqual(
Attachment.objects.attachments_for_object(self.obj).count(), 0
)
def test_form_errors_are_returned_as_json(self):
self.client.login(**self.cred_jon)
response = self._upload_testfile(
file_content=b"<xml>this is not allowed</xml>",
HTTP_X_RETURN_FORM_ERRORS=True,
)
self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST)
self.assertEqual(response.headers.get("Content-Type"), "application/json")
# this should be a dict
errors = json.loads(response.content)
# note: field errors are a list of string messages
self.assertEqual(errors["attachment_file"], ["XML is forbidden"])
self.assertEqual(Attachment.objects.count(), 0)
self.assertEqual(
Attachment.objects.attachments_for_object(self.obj).count(), 0
)

View file

@ -0,0 +1,11 @@
from django.contrib import admin
from ...admin import AttachmentInlines
from .models import TestModel
class TestModelAdmin(admin.ModelAdmin):
inlines = [AttachmentInlines]
admin.site.register(TestModel, TestModelAdmin)

View file

@ -0,0 +1,19 @@
"""
Custom app config to demonstrate and test the 'custom attachment validators'
functionality.
"""
from attachments.apps import AttachmentsConfig
from django.forms import ValidationError
def deny_xml_uploads(uploaded_file):
if uploaded_file.read().find(b"<xml>") > -1:
raise ValidationError("XML is forbidden")
class CustomizedAttachmentsApp(AttachmentsConfig):
"""
Adds a custom form validator function.
"""
attachment_validators = (deny_xml_uploads,)

View file

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name='TestModel',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('title', models.CharField(max_length=100)),
],
bases=(models.Model,),
),
]

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2018-01-04 12:47
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('testapp', '0001_initial'),
]
operations = [
migrations.AlterModelTable(
name='testmodel',
table='testapp_testmodel',
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 3.2.7 on 2023-03-11 11:15
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('testapp', '0002_auto_20180104_1247'),
]
operations = [
migrations.CreateModel(
name='ModelWithUuidPk',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('title', models.CharField(max_length=100)),
],
options={
'db_table': 'testapp_uuid4_model',
},
),
]

View file

@ -0,0 +1,23 @@
import uuid
from django.db import models
class TestModel(models.Model):
title = models.CharField(max_length=100)
class Meta:
db_table = "testapp_testmodel"
def get_absolute_url(self):
return "/"
class ModelWithUuidPk(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
title = models.CharField(max_length=100)
class Meta:
db_table = "testapp_uuid4_model"
def get_absolute_url(self):
return "/"

View file

@ -0,0 +1,80 @@
import os
DEBUG = True
TESTAPP_DIR = os.path.abspath(os.path.dirname(__file__))
SECRET_KEY = "testsecretkey"
if os.environ.get("DJANGO_DATABASE_ENGINE") == "postgresql":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"USER": "postgres",
"NAME": "attachments",
"HOST": "localhost",
"PORT": 5432,
}
}
elif os.environ.get("DJANGO_DATABASE_ENGINE") == "mysql":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"USER": "root",
"NAME": "attachments",
"HOST": "127.0.0.1",
"PORT": 3306,
}
}
else:
DATABASES = {
"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "tests.db"}
}
DATABASES["default"].update(
{
"PASSWORD": os.environ.get("DATABASE_PASSWORD", "testing"),
}
)
MEDIA_ROOT = os.path.join(TESTAPP_DIR, "uploads")
ROOT_URLCONF = "attachments.tests.testapp.urls"
INSTALLED_APPS = [
"attachments.tests.testapp",
"attachments.tests.testapp.apps.CustomizedAttachmentsApp",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
]
MIDDLEWARE = (
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
)
MIDDLEWARE_CLASSES = (
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
)
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(TESTAPP_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.template.context_processors.i18n",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
}
]

View file

@ -0,0 +1,16 @@
{% load attachments_tags %}
<h1>{{ object.title }}</h1>
<h2>Object has {% attachments_count object %} attachments</h2>
{% get_attachments_for object as attachments_list %}
{% for att in attachments_list %}
<p>
{{ att }}
{{ att.attachment_file.url }}
{{ att.filename }}
{% attachment_delete_link att %}
</p>
{% endfor %}
{% attachment_form object %}

View file

@ -0,0 +1,25 @@
try:
from django.urls import re_path as url
except ImportError:
from django.conf.urls import url
from django.conf.urls import include
from django.contrib import admin
from django.views.generic import DetailView
from .models import TestModel
admin.autodiscover()
urlpatterns = [
url(r"^attachments/", include("attachments.urls", namespace="attachments")),
url(r"^admin/", admin.site.urls),
url(
r"^testapp/(?P<pk>\d+)/$",
DetailView.as_view(
template_name="testmodel_detail.html",
queryset=TestModel.objects.all(),
),
name="testapp-detail",
),
]

View file

@ -0,0 +1,24 @@
from __future__ import unicode_literals
try:
from django.urls import re_path as url
except ImportError:
from django.conf.urls import url
from .views import add_attachment, delete_attachment
app_name = "attachments"
urlpatterns = [
url(
r"^add-for/(?P<app_label>[\w\-]+)/(?P<model_name>[\w\-]+)/(?P<pk>\d+)/$",
add_attachment,
name="add",
),
url(
r"^add-for/(?P<app_label>[\w\-]+)/(?P<model_name>[\w\-]+)/(?P<pk>[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12})/$",
add_attachment,
name="add",
),
url(r"^delete/(?P<attachment_pk>\d+)/$", delete_attachment, name="delete"),
]

View file

@ -0,0 +1,89 @@
from __future__ import unicode_literals
import os
from http import HTTPStatus
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils.translation import gettext
from django.views.decorators.http import require_POST
from .forms import AttachmentForm
from .models import Attachment
def add_url_for_obj(obj):
return reverse(
"attachments:add",
kwargs={
"app_label": obj._meta.app_label,
"model_name": obj._meta.model_name,
"pk": obj.pk,
},
)
def remove_file_from_disk(f):
if getattr(
settings, "DELETE_ATTACHMENTS_FROM_DISK", False
) and os.path.exists(f.path):
try:
os.remove(f.path)
except OSError:
pass
@require_POST
@login_required
def add_attachment(
request,
app_label,
model_name,
pk,
template_name="attachments/add.html",
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
form = AttachmentForm(request.POST, request.FILES)
if form.is_valid():
form.save(request, obj)
messages.success(request, gettext("Your attachment was uploaded."))
return HttpResponseRedirect(next_)
if request.headers.get("X-Return-Form-Errors", None):
return JsonResponse(form.errors, status=HTTPStatus.BAD_REQUEST)
template_context = {
"form": form,
"form_url": add_url_for_obj(obj),
"next": next_,
}
template_context.update(extra_context or {})
return render(request, template_name, template_context)
@login_required
def delete_attachment(request, attachment_pk):
g = get_object_or_404(Attachment, pk=attachment_pk)
if (
request.user.has_perm("attachments.delete_attachment")
and request.user == g.creator
) or request.user.has_perm("attachments.delete_foreign_attachments"):
remove_file_from_disk(g.attachment_file)
g.delete()
messages.success(request, gettext("Your attachment was deleted."))
next_ = request.GET.get("next") or "/"
return HttpResponseRedirect(next_)

View file

@ -10,8 +10,7 @@ except Exception:
class AppSettings(object):
"""
"""
""" """
@property
def MAX_ATTEMPTS(self):
"""Control how many times a task will be attempted."""

View file

@ -1,3 +1,3 @@
Copy-and-pasted at commit https://github.com/coinkite/connectrum/commit/c893bc2100de6acebbdf0bf67b62e6cb9ce1c7be and extended for Labelbase.
Friendly-forked at commit https://github.com/coinkite/connectrum/commit/c893bc2100de6acebbdf0bf67b62e6cb9ce1c7be and extended for Labelbase.
MIT Licence https://github.com/coinkite/connectrum/blob/master/LICENSE

View file

@ -24,11 +24,13 @@ async def interact(conn, server_info, method, utxo):
except Exception as ex:
blocktime = 0
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)
return (txid, index, address, value, blocktime, utxo)
except ElectrumErrorResponse as ex:
logger.error("ERROR: {} {}".format(ex, conn.last_error))
finally:
conn.close()
@ -70,20 +72,33 @@ def checkup_label(label_id, loop):
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 or "electrum.emzy.de"
electrum_ports = elem.labelbase.user.profile.electrum_ports or "s50002"
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_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)
unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.address.listunspent", address))
try:
unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.address.listunspent", address))
except:
conn.last_error = None # reset error if needed
unspents = loop.run_until_complete(interact_addr(conn, server_info, "blockchain.scripthash.listunspent", address))
utxo_value = 0

View file

@ -0,0 +1,19 @@
# Generated by Django 3.2.24 on 2024-03-29 17:38
from django.db import migrations
import jsonfield.fields
class Migration(migrations.Migration):
dependencies = [
('finances', '0009_alter_outputstat_last_error'),
]
operations = [
migrations.AddField(
model_name='outputstat',
name='next_input_attributes',
field=jsonfield.fields.JSONField(default={}),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2024-04-11 15:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finances', '0010_outputstat_next_input_attributes'),
]
operations = [
migrations.AlterField(
model_name='outputstat',
name='network',
field=models.CharField(choices=[('mainnet', 'Mainnet'), ('testnet', 'Testnet')], default='mainnet', help_text="Choose the network for this labelbase's label output.", max_length=10),
),
]

View file

@ -43,7 +43,7 @@ class OutputStat(models.Model):
confirmed_at_block_time = models.IntegerField(default=0)
last_error = JSONField(default={})
next_input_attributes = JSONField(default={}) # will be used for fee estimation
MAINNET = 'mainnet'
TESTNET = 'testnet'
@ -56,7 +56,7 @@ class OutputStat(models.Model):
max_length=10,
choices=NETWORK_CHOICES,
default='mainnet',
help_text="Choose the network for this labelbase."
help_text="Choose the network for this labelbase's label output."
)
class Meta:

View file

@ -8,6 +8,7 @@ from pymempool import MempoolAPI
from labellabor.utils import extract_fiat_value
from labelbase.utils import compute_type_ref_hash
from finances.models import OutputStat
from attachments.models import LabelAttachment
class Labelbase(models.Model):
"""
@ -189,6 +190,14 @@ class Label(models.Model):
val, cur = extract_fiat_value(self.label)
return output.output_metrics_dict(tracked_fiat_value=val, fiat_currency=cur)
def get_label_attachment(self):
type_ref_hash = compute_type_ref_hash(self.type, self.ref)
label_attachment, _ = LabelAttachment.objects.get_or_create(
user=self.labelbase.user,
type_ref_hash=type_ref_hash,
network=self.labelbase.network)
return label_attachment
def get_absolute_url(self):
"""
Is used by "edit label" functionality.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View file

@ -1,11 +1,17 @@
from django import template
from django.template import Library, Node, VariableDoesNotExist
from django.conf import settings
from labelbase.forms import LabelbaseForm
from labelbase.forms import ExportLabelsForm
register = template.Library()
@register.simple_tag
def is_self_hosted():
return settings.SELF_HOSTED
@register.simple_tag
def labelbaseform():
return LabelbaseForm()

View file

@ -36,6 +36,7 @@ CRYPTOGRAPHY_SALT = proj_config.get("internal", "crypto_salt")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True # proj_config.getboolean("internal", "debug")
SELF_HOSTED = proj_config.getboolean("internal", "self_hosted", fallback=True)
#if DEBUG:
ALLOWED_HOSTS = ["*"] # we don't know your host config, keep like that at the moment.
#else:
@ -54,7 +55,7 @@ sentry_sdk.init(
traces_sample_rate=1.0,
send_default_pii=True, # must be "True" here, will skip or omit in `before_send` callback
)
sentry_sdk.set_tag("version", "2.0.0")
sentry_sdk.set_tag("version", "2.1.0")
LOGGING = {
@ -117,6 +118,7 @@ INSTALLED_APPS = [
"knowledge_base",
"hashtags",
"statusapp",
"attachments",
]

View file

@ -272,6 +272,10 @@ urlpatterns = [
"",
include("user_sessions.urls", "user_sessions")
),
path(
"attachments/",
include('attachments.urls', namespace='attachments')
),
path(
"has_seen_welcome_popup/",
has_seen_welcome_popup,

View file

@ -572,6 +572,9 @@ class LabelUpdateView(UpdateView):
action = self.kwargs['action']
if action == 'labeling':
return "label_edit_labeling.html"
if action == 'attachments' and settings.SELF_HOSTED and \
self.object.labelbase.user.profile.use_attachments:
return "label_edit_attachments.html"
elif action == 'output-details':
return "label_edit_output_details.html"

View file

@ -6,7 +6,21 @@
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://labelbase.space/" />
<meta property="og:title" content="Labelbase" />
<meta property="og:description" content="All-in-one coin control/label management service for Bitcoin. Add, import, export labels. REST API for integration. Supports BIP-329 format. Stay organized!" />
<meta property="og:image" content="https://labelbase.space{% static 'ogimg.png' %}" />
<!-- twitter card tags additive with the og: tags -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:domain" value="labelbase.space" />
<meta name="twitter:title" value="Labelbase" />
<meta name="twitter:description" value="All-in-one coin control/label management service for Bitcoin. Add, import, export labels. REST API for integration. Supports BIP-329 format. Stay organized!" />
<meta name="twitter:image" content="https://labelbase.space{% static 'ogimg.png' %}" />
<meta name="twitter:url" value="https://labelbase.space/" />
<title>{% block title %}{% endblock %} | Labelbase</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="{% static 'css/bootstrap.min.css' %}" rel="stylesheet"
@ -116,17 +130,19 @@
{% if labelbase.id == active_labelbase_id %}
<ul style="list-style-type: none;">
<li><a class="nav-link" href="{{ labelbase.get_absolute_url }}" style="font-size: .875rem;"><span data-feather="tag" class="align-text-bottom"></span> Labels</a></li>
<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;" 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>
<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 "labelbase_fix_and_manage" labelbase_id=labelbase.id %}"><span data-feather="activity" class="align-text-bottom"></span>UTXOs Health</a></li>
<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_tree_maps" pk=labelbase.id %}unspent-outputs/"><span data-feather="grid" class="align-text-bottom"></span> Tree Map</a></li>
{% comment %}<li><a class="nav-link" style="font-size: .875rem;" href="{% url "labelbase_stats_and_kpi" labelbase_id=labelbase.id %}"><span data-feather="pie-chart" class="align-text-bottom"></span> Stats & KPIs</a></li>{% endcomment %}
{% if request.user.profile.use_fiatfinances %}
<li><a class="nav-link" style="font-size: .875rem;"><span data-feather="dollar-sign" class="align-text-bottom"></span> Fiat Finances</a></li>
{% endif %}
<li><a class="nav-link" style="font-size: .875rem;" data-bs-toggle="modal" data-bs-target="#importLabelbaseModal"><span data-feather="upload-cloud" class="align-text-bottom"></span> Import</a></li>
<li><a class="nav-link" style="font-size: .875rem;" data-bs-toggle="modal" data-bs-target="#exportLabelbaseModal"><span data-feather="download-cloud" class="align-text-bottom"></span> Export</a></li>
{% if labelbase.user.profile.use_hashtags %}
<li><a href="{{ labelbase.get_hashtags_url }}" class="nav-link" style="font-size: .875rem;"><span data-feather="hash" class="align-text-bottom"></span> Hashtags</a></li>
{% endif %}
</ul>
{% endif %}

View file

@ -55,7 +55,7 @@
</ul>
</div>
<div class="my-2 text-center"><small>
<a class="text-600 me-1 termslink" href="JavaScript:void(0);" onclick="window.location='{% url 'privacy_policy' %}';">Privacy Policy</a><a class="text-600 mx-1 termslink" href="JavaScript:void(0);" onclick="window.location='{% url 'terms' %}';">Terms</a><a class="text-600 mx-1 termslink">v2.0.0</a></small>
<a class="text-600 me-1 termslink" href="JavaScript:void(0);" onclick="window.location='{% url 'privacy_policy' %}';">Privacy Policy</a><a class="text-600 mx-1 termslink" href="JavaScript:void(0);" onclick="window.location='{% url 'terms' %}';">Terms</a><a class="text-600 mx-1 termslink">v2.1.0</a></small>
</div>
</div>
</div>

View file

@ -0,0 +1,5 @@
{% extends "attachments/base.html" %}
{% block content %}
{% include "attachments/add_form.html" %}
{% endblock %}

View file

@ -0,0 +1,14 @@
{% load i18n %}
{% load bootstrap %}
{% if form %}
<form method="post" enctype="multipart/form-data" action="{{ form_url }}" class="add-attachment">
{% csrf_token %}
<input type="hidden" name="next" value="{{ next }}"/>
{{ form|bootstrap }}
<br>
<input type="submit" class="btn btn-primary" value="{% trans "Add attachment" %}"/>
</form>
{% endif %}

View file

@ -0,0 +1 @@
{% block content %}{% endblock %}

View file

@ -0,0 +1,2 @@
{% load i18n %}
{% if delete_url %}<a class="delete-attachment" href="{{ delete_url }}?next={{ next }}">{% trans "Delete" %}</a>{% endif %}

View file

@ -10,35 +10,59 @@
</div>
<p class="fs-6 text-muted">
Update your Electrum server information.
<br><br>
<!-- Trusting Craig on this one. https://github.com/sparrowwallet/sparrow/blob/f003b6d732bd73d93fe904659e61e0d56f6cb280/src/main/java/com/sparrowwallet/sparrow/net/PublicElectrumServer.java -->
If you don't run your own Electrum server, here are some community trusted mainnet servers: <br>
<ul>
<li> electrum.emzy.de / s50002</li>
<li> electrum.blockstream.info / s50002</li>
<li> <s>bitcoin.lu.ke / s50002</s></li>
<!--li> umbrel.local / t50001</s> <sup>1</sup></li-->
</ul>
<form action="" method="POST">
{% csrf_token %}
<table>
{{ form|bootstrap }}
<tr>
<td>&nbsp;</td>
<td></td>
</tr>
<tr>
<td colspan="2" style="text-align: center;">
<button class="btn btn-primary" type="submit">Update Electrum ServerInfo</button>
</td>
</tr>
</table>
</form>
<!--hr>
<small><sup>1</sup> Access your local Umbrel node, within your local network. </small-->
If you don't operate your own Electrum server, you have the option to connect to a community-trusted server.<br>
However, keep in mind that using a public server allows it to view your outputs and transactions.
</p>
<div class="row" style="padding-top: 1.1rem;">
<h5 class="text-muted">Community-trusted Electrum servers</h5>
<div class="col-md-6">
<p class="fs-6 text-muted">
<strong>Mainnet: </strong>
<ul>
<li>electrum.emzy.de / s50002</li>
<li>electrum.blockstream.info / s50002</li>
</ul>
</p>
</div>
<div class="col-md-6">
<p class="fs-6 text-muted">
<strong>Testnet:</strong>
<ul>
<li>testnet.qtornado.com / s51002</li>
<li>testnet.aranguren.org / s51002</li>
</ul>
</p>
</div>
</div>
<div class="row">
<form action="" method="POST">
{% csrf_token %}
<p class="fs-6 text-muted">
<table>
{{ form|bootstrap }}
<tr>
<td>&nbsp;</td>
<td></td>
</tr>
<tr>
<td colspan="2" style="text-align: center;">
<button class="btn btn-primary" type="submit">Update Electrum Server</button>
</td>
</tr>
</table>
</p>
</form>
</div>
<div class="row" style="padding-top: 2.1rem;">
</div>
{% endblock %}

View file

@ -2,14 +2,38 @@
{% load i18n %}
{% load sekizai_tags %}
{% load labelbase_tags %}
{% load backgroundtask_tags %}
{% load attachments_tags %}
{% block title %}{{ object.type }} {{ object.ref }}{% endblock %}
{% block content %}
{% get_attachments_for object.get_label_attachment as my_attachments %}
<ul class="nav nav-tabs" style="padding-top: 2rem; ">
<li class="nav-item">
<a class="nav-link {% if action == "update" %}active" aria-current="page" {% else %}"{% endif %} href="{% url 'edit_label' object.id %}">Label Detail</a>
</li>
{% is_self_hosted as my_labelbase_is_self_hosted %}
{% if object.labelbase.user.profile.use_attachments %}
<li class="nav-item">
{% if my_labelbase_is_self_hosted %}
<a class="nav-link {% if action == "attachments" %}active " aria-current="page" {% else %}"{% endif %}
href="{% url 'edit_label' object.id %}attachments/">
Attachments
{% if my_attachments.count %}
<span class="badge text-bg-secondary top-0 rounded-pill " >{% attachments_count object.get_label_attachment %}</span>
{% endif %}
</a>
{% else %}
<a class="nav-link disabled">Attachments</a>
{% endif %}
</li>
{% endif %}
{% comment %}<!--
<li class="nav-item">
@ -69,8 +93,17 @@
{% endswitch %}
{% if object.ref == "54e48e5f5c656b26c3bca14a8c95aa583d07ebe84dde3b7dd4a78f4e4186e713" %}
<li class="nav-item">
<a class="nav-link" href="/static/bitcoin.pdf">Bitcoin White Paper</a>
</li>
{% endif %}
</ul>
<div style="padding-top: 0rem; padding-bottom: 2.1rem; ">
@ -90,33 +123,58 @@
<strong>Ouch!</strong> Got "{{ output.last_error.message }}{% if output.last_error.code %} (code {{ output.last_error.code }}){% endif %}" from Electrum.
</div>
{% endif %}
{% 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 %}
{% is_label_id_in_queue object.id as is_in_queue %}
{% if is_in_queue %}
<div class="alert bd-callout bd-callout-info">
<!--div class="btn-group" role="group" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;">
<button type="button" class="btn btn-sm btn-outline-danger dropdown-toggle " data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=none">Verify output status</a></li>
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=true">Mark output as spent</a></li>
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=false">Mark output as unspent</a></li>
</ul>
</div-->
<strong>Output in queue!</strong> This output is currently in the processing queue. It will be checked shortly.
</div>
{% else %}
{% if object.type == "output" %}
{% switch output.get_spent_status %}
{% case "spent" %}
<div class="bd-callout bd-callout-warning">
<strong>Output spent!</strong> Blockchain records indicate that this output has been spent in another transaction.
</div>
{% case "unspent" %}
<div class="bd-callout bd-callout-good">
<strong>Output unspent!</strong> Blockchain records indicate that this output has not been spent yet.
</div>
{% case "unconfirmed" %}
<div class="alert bd-callout bd-callout-info">
<div class="btn-group" role="group" style="position: absolute; top: -8px; right: -4px; padding: 1.25rem 1rem;">
<button type="button" class="btn btn-sm btn-outline-info dropdown-toggle " data-bs-toggle="dropdown" aria-expanded="false">
Actions
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'outputstat_update_redirect' output_stats_id=output.id label_id=object.id %}?force-spent=none">
Verify output status</a></li>
</ul>
</div>
<strong>Output unconfirmed!</strong> Blockchain records indicate that this output has not been confirmed yet.
</div>
{% endswitch %}
{% endif %}
{% endif %}
<!--div class="bd-callout bd-callout-warning">
<strong>Heads up!</strong> There are multiple records for this transaction output. <a href="">Review & merge</a>
</div-->
@ -128,6 +186,7 @@
</div>
{% endif %}
{% endcomment %}
{% block label_edit_content %}
{% endblock %}

View file

@ -0,0 +1,45 @@
{% extends "label_edit.html" %}
{% load bootstrap %}
{% load i18n %}
{% load attachments_tags %}
{% block label_edit_content %}
{% if my_attachments %}
<table class="table">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Attachment</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
{% for attachment in my_attachments %}
<tr>
<td>{{ attachment.id }}</td>
<td><a href="{{ attachment.attachment_file.url }}">{{ attachment.filename }}</a></td>
<td>{% attachment_delete_link attachment %}</td>
</tr>
{% empty %}
{% endfor %}
</tbody>
</table>
{% else %}
<p><br>
Label has {% attachments_count object.get_label_attachment %} attachments.
</p>
{% endif %}
<br>
{% attachment_form form.instance %}
{% endblock %}

View file

@ -7,7 +7,7 @@
<!--
{{  res_tx|safe }}
-->
{% if action == "output-details" %}
{% if action == "label-attachments" %}
{% if form.instance.type == "output" %}
<br>
<pre>
@ -16,8 +16,21 @@
Confirmed at block height: {{ output.confirmed_at_block_height }}
Confirmed at block time: {{ output.confirmed_at_block_time }}
Network: {{ output.get_network_display }}
{% if output.next_input_attributes %}
Fee estimation will be made based on:
{{ output.next_input_attributes }}
{% if output.next_input_attributes.input_n %}
* Assuming {{ output.next_input_attributes.input_m }}-of-{{ output.next_input_attributes.input_n }} multisig
{% else %}
{% endif %}
{% endif %}
</pre>
{% endif %}
{% endif %}
{% endblock %}

View file

@ -1,7 +1,7 @@
{% extends "label_edit.html" %}
{% load bootstrap %}
{% load i18n %}
{% load attachments_tags %}
{% block label_edit_content %}
<div class="lb-header">
@ -33,4 +33,6 @@
</div><!-- lb-header container -->
{% endblock %}

View file

@ -1,8 +1,13 @@
{% extends "_base.html" %}
{% load i18n %}
{% load sekizai_tags %}
{% load labelbase_tags %}
{% block content %}
<div >
<div>
{% include "profile_header_menu.html" %}
<div style="padding-top: 2rem; ">
@ -11,7 +16,7 @@
</div>
{% is_self_hosted as my_labelbase_is_self_hosted %}
<div class="">
<h4>Extensions</h4>
@ -35,12 +40,26 @@
</div>
</td>
</tr>
<tr >
<tr>
<td style="width:21%;"><b>Tree Map</b></td>
<td style="width:75%;"><small>Enable or disable Tree Map.</small></td>
<td>
<div class="form-check form-switch">
<input class="form-check-input user-setting" type="checkbox" {% if request.user.profile.use_treemap %}checked="checked"{% endif %} id="use_treemap">
<input class="form-check-input user-setting" type="checkbox" {% if request.user.profile.use_treemap %}checked="checked"{% endif %} id="use_treemap">
</div>
</td>
</tr>
<tr >
<td style="width:21%;"><b>Attachments</b></td>
<td style="width:75%;"><small>Enable or disable attachments. This extension is available only for self-hosted Labelbase instances.</small></td>
<td>
<div class="form-check form-switch">
{% if my_labelbase_is_self_hosted %}
<input class="form-check-input user-setting" type="checkbox" {% if request.user.profile.use_attachments %}checked="checked"{% endif %} id="use_attachments">
{% else %}
<input class="form-check-input " type="checkbox" id="use_attachments" disabled>
{% endif %}
</div>
</td>
</tr>
@ -81,17 +100,16 @@
<td style="width:75%;">
<small>Check and update all unspent outputs using <a href="/account/userprofile-electrum/">Electrum</a>.</small></td>
<td>
<td>
<div class="form-check form-switch">
<input class="form-check-input user-setting" type="checkbox" {% if request.user.profile.update_utxo_on_login %}checked="checked"{% endif %} id="update_utxo_on_login">
</div>
</td>
</td>
</tr>
</table>
</div>

View file

@ -23,13 +23,18 @@ class MempoolForm(forms.ModelForm):
class ElectrumServerInfoForm(forms.ModelForm):
class Meta:
model = Profile
fields = ['electrum_hostname', 'electrum_ports']
fields = ['electrum_hostname', 'electrum_ports',
'electrum_hostname_test', 'electrum_ports_test']
labels = {
'electrum_hostname': 'Electrum Hostname',
'electrum_ports': 'Electrum Protocol + Port',
'electrum_hostname': 'Electrum Hostname (Mainnet)',
'electrum_ports': 'Electrum Protocol + Port (Mainnet)',
'electrum_hostname_test': 'Electrum Hostname (Testnet)',
'electrum_ports_test': 'Electrum Protocol + Port (Testnet)',
}
class ProfileCurrencyForm(forms.ModelForm):
class Meta:
model = Profile

View file

@ -0,0 +1,23 @@
# Generated by Django 3.2.24 on 2024-03-29 22:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('userprofile', '0015_alter_profile_use_sentry'),
]
operations = [
migrations.AddField(
model_name='profile',
name='electrum_hostname_test',
field=models.CharField(blank=True, default='testnet.qtornado.com', max_length=255),
),
migrations.AddField(
model_name='profile',
name='electrum_ports_test',
field=models.CharField(blank=True, default='t51001', max_length=6),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2024-03-29 23:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('userprofile', '0016_auto_20240329_2233'),
]
operations = [
migrations.AlterField(
model_name='profile',
name='electrum_ports_test',
field=models.CharField(blank=True, default='s51002', max_length=6),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.25 on 2024-04-13 22:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('userprofile', '0017_alter_profile_electrum_ports_test'),
]
operations = [
migrations.AddField(
model_name='profile',
name='use_attachments',
field=models.BooleanField(default=True),
),
]

View file

@ -26,6 +26,10 @@ class Profile(models.Model):
electrum_hostname = models.CharField(max_length=255, default="electrum.emzy.de", blank=True)
electrum_ports = models.CharField(max_length=6, default="s50002", blank=True)
# electrum testnet
electrum_hostname_test = models.CharField(max_length=255, default="testnet.qtornado.com", blank=True)
electrum_ports_test = models.CharField(max_length=6, default="s51002", blank=True)
avatar_url = models.CharField(max_length=160,
default="/static/avatar.jpg",
blank=True,
@ -35,6 +39,7 @@ class Profile(models.Model):
auto_cleanup = models.BooleanField(default=False)
use_hashtags = models.BooleanField(default=True)
use_treemap = models.BooleanField(default=True)
use_attachments = models.BooleanField(default=True)
use_sentry = models.BooleanField(default=True)
use_chatwoot = models.BooleanField(default=False)
use_fiatfinances = models.BooleanField(default=True)

View file

@ -92,6 +92,10 @@ class ElectrumInfoUpdateView(UpdateView):
profile.electrum_hostname = profile._meta.get_field('electrum_hostname').get_default()
if not profile.electrum_ports:
profile.electrum_ports = profile._meta.get_field('electrum_ports').get_default()
if not profile.electrum_hostname_test:
profile.electrum_hostname_test = profile._meta.get_field('electrum_hostname_test').get_default()
if not profile.electrum_ports_test:
profile.electrum_ports_test = profile._meta.get_field('electrum_ports_test').get_default()
profile.save()
messages.success(self.request, "<strong>Success!</strong> Electrum ServerInfo updated successfully.")
return super().form_valid(form)

View file

@ -14,7 +14,7 @@ http {
upstream labelbase_django {
server labelbase_django:8000;
}
server {
listen 8080;
client_max_body_size 69M;
@ -46,6 +46,14 @@ http {
break;
}
location /attachments/attachment {
autoindex off;
index index.html;
root /app;
break;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;