Initial (bugged) work on tag editor

This commit is contained in:
Paul Konstantin Gerke 2022-02-15 10:13:35 +01:00
parent 2037035e6d
commit bf49cc92ad
16 changed files with 404 additions and 9949 deletions

View File

@ -22,6 +22,7 @@ python-dotenv = "*"
pyyaml = "*"
uritemplate = "*"
whitenoise = "*"
django-taggit = "==2.1.0"
[dev-packages]
coveralls = "*"
@ -30,3 +31,4 @@ mkdocs = "*"
mkdocs-material = "*"
tblib = "*"
black = "*"
pydevd = "*"

View File

@ -6,6 +6,10 @@ from rest_framework.exceptions import ValidationError
from django.contrib.auth.models import User
from django.utils import timezone
from taggit.serializers import (
TagListSerializerField, TaggitSerializer
)
from core import models
@ -125,10 +129,12 @@ class FeedingSerializer(CoreModelWithDurationSerializer):
)
class NoteSerializer(CoreModelSerializer):
class NoteSerializer(TaggitSerializer, CoreModelSerializer):
class Meta:
model = models.Note
fields = ("id", "child", "note", "time")
fields = ("id", "child", "note", "time", "tags")
tags = TagListSerializerField()
class SleepSerializer(CoreModelWithDurationSerializer):

View File

@ -8,7 +8,6 @@ from core import models
from . import serializers, filters
from .mixins import TimerFieldSupportMixin
class ChildViewSet(viewsets.ModelViewSet):
queryset = models.Child.objects.all()
serializer_class = serializers.ChildSerializer

View File

@ -26,6 +26,7 @@ INSTALLED_APPS = [
"api",
"babybuddy",
"core",
"taggit",
"dashboard",
"reports",
"axes",

View File

@ -177,3 +177,14 @@ class WeightAdmin(ImportExportMixin, ExportActionMixin, admin.ModelAdmin):
"weight",
)
resource_class = WeightImportExportResource
class BabyBuddyTaggedItemInline(admin.StackedInline):
model = models.BabyBuddyTagged
@admin.register(models.BabyBuddyTag)
class BabyBuddyTagAdmin(admin.ModelAdmin):
inlines = [BabyBuddyTaggedItemInline]
list_display = ["name", "slug", "color", "last_used"]
ordering = ["name", "slug"]
search_fields = ["name"]
prepopulated_fields = {"slug": ["name"]}

View File

@ -5,7 +5,7 @@ from django.utils import timezone
from django.utils.translation import gettext as _
from core import models
from core.widgets import TagsEditor
def set_initial_values(kwargs, form_type):
"""
@ -161,7 +161,7 @@ class FeedingForm(CoreModelForm):
class NoteForm(CoreModelForm):
class Meta:
model = models.Note
fields = ["child", "note", "time"]
fields = ["child", "note", "time", "tags"]
widgets = {
"time": forms.DateTimeInput(
attrs={
@ -169,6 +169,7 @@ class NoteForm(CoreModelForm):
"data-target": "#datetimepicker_time",
}
),
#"tags": TagsEditor()
}

View File

@ -0,0 +1,20 @@
# Generated by Django 4.0.2 on 2022-02-11 10:47
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('taggit', '0004_alter_taggeditem_content_type_alter_taggeditem_tag'),
('core', '0018_bmi_headcircumference_height'),
]
operations = [
migrations.AddField(
model_name='note',
name='tags',
field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
),
]

View File

@ -0,0 +1,46 @@
# Generated by Django 4.0.2 on 2022-02-11 15:44
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('core', '0019_note_tags'),
]
operations = [
migrations.CreateModel(
name='BabyBuddyTag',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True, verbose_name='name')),
('slug', models.SlugField(max_length=100, unique=True, verbose_name='slug')),
('color', models.CharField(max_length=32, verbose_name='Color')),
],
options={
'verbose_name': 'Tag',
'verbose_name_plural': 'Tags',
},
),
migrations.CreateModel(
name='BabyBuddyTagged',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.IntegerField(db_index=True, verbose_name='object ID')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_tagged_items', to='contenttypes.contenttype', verbose_name='content type')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_items', to='core.babybuddytag')),
],
options={
'abstract': False,
},
),
migrations.AlterField(
model_name='note',
name='tags',
field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='core.BabyBuddyTagged', to='core.BabyBuddyTag', verbose_name='Tags'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-02-11 17:27
import core.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0020_babybuddytag_babybuddytagged_alter_note_tags'),
]
operations = [
migrations.AlterField(
model_name='babybuddytag',
name='color',
field=models.CharField(default='#7F7F7F', max_length=32, validators=[core.models.validate_html_color], verbose_name='Color'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-02-13 13:04
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('core', '0021_alter_babybuddytag_color'),
]
operations = [
migrations.AddField(
model_name='babybuddytag',
name='last_used',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.0.2 on 2022-02-14 15:50
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('core', '0022_babybuddytag_last_used'),
]
operations = [
migrations.AlterField(
model_name='note',
name='tags',
field=taggit.managers.TaggableManager(blank=True, help_text='A comma-separated list of tags.', through='core.BabyBuddyTagged', to='core.BabyBuddyTag', verbose_name='Tags'),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.0.2 on 2022-02-15 09:11
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('taggit', '0004_alter_taggeditem_content_type_alter_taggeditem_tag'),
('core', '0023_alter_note_tags'),
]
operations = [
migrations.AlterField(
model_name='note',
name='tags',
field=taggit.managers.TaggableManager(help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag', verbose_name='Tags'),
),
]

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
import re
from datetime import timedelta
from django.conf import settings
@ -9,7 +10,10 @@ from django.utils.text import slugify
from django.utils import timezone
from django.utils.text import format_lazy
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now
from taggit.managers import TaggableManager
from taggit.models import TagBase, GenericTaggedItemBase, TaggedItemBase
def validate_date(date, field_name):
"""
@ -70,6 +74,41 @@ def validate_time(time, field_name):
{field_name: _("Date/time can not be in the future.")}, code="time_invalid"
)
def validate_html_color(s: str):
return re.match(r"^#[0-9A-F]{6}$", s) is not None
class BabyBuddyTag(TagBase):
class Meta:
verbose_name = _("Tag")
verbose_name_plural = _("Tags")
color = models.CharField(
"Color",
max_length=32,
default="#7F7F7F",
validators=[validate_html_color]
)
last_used = models.DateTimeField(
default=now,
blank=False,
)
def save(self, *args, **kwargs):
print("BBT SAVE")
return super().save(*args, **kwargs)
class BabyBuddyTagged(GenericTaggedItemBase):
tag = models.ForeignKey(
BabyBuddyTag,
on_delete=models.CASCADE,
related_name="%(app_label)s_%(class)s_items",
)
def save(self, *args, **kwargs) -> None:
print("BabyBuddyTagged", args, kwargs)
return super().save(*args, **kwargs)
class Child(models.Model):
model_name = "child"
@ -241,6 +280,14 @@ class Feeding(models.Model):
validate_duration(self)
validate_unique_period(Feeding.objects.filter(child=self.child), self)
from taggit.managers import _TaggableManager
class TTT(_TaggableManager):
def set(self, tags, *, through_defaults=None, **kwargs):
return super().set(tags, through_defaults=through_defaults, **kwargs)
class TT(TaggableManager):
def save_form_data(self, instance, value):
return super().save_form_data(instance, value)
class Note(models.Model):
model_name = "note"
@ -251,6 +298,7 @@ class Note(models.Model):
time = models.DateTimeField(
default=timezone.now, blank=False, verbose_name=_("Time")
)
tags = TaggableManager()
objects = models.Manager()

View File

@ -0,0 +1,119 @@
<div>
{{ widget }}
</div>
<div class="form_control"
{% for k, v in widget.attrs.items %}
{{ k }}={{ v }}
{% endfor %}>
<span class="prototype-tag btn badge badge-pill cursor-pointer" style="display: none;">
PROTOTYPE
<span class="add-remove-icon pl-1 pr-1">+ or -</span>
</span>
<div class="current_tags" style="min-height: 2em;">
{% for t in widget.value %}
<span data-value="{{ t.name }}" data-color="{{ t.color }}" class="tag btn badge badge-pill cursor-pointer" style="background-color: {{ t.color }};">
{{ t.name }}
<span class="add-remove-icon pl-1 pr-1">-</span>
</span>
{% endfor %}
</div>
<div class="new-tags">
<span>Suggestions:</span>
{% for t in widget.tag_suggestions.quick %}
<span data-value="{{ t.name }}" data-color="{{ t.color }}" class="tag btn badge badge-pill cursor-pointer" style="background-color: {{ t.color }};">
{% if not forloop.first %},{% endif %}
{{ t.name }}
<span class="add-remove-icon pl-1 pr-1">+</span>
</span>
{% endfor %}
</div>
<script>
window.addEventListener('load', () => {
const widget = document.getElementById('{{ widget.attrs.id }}');
const prototype = widget.querySelector('.prototype-tag');
const currentTags = widget.querySelector('.current_tags');
const newTags = widget.querySelector('.new-tags');
const inputElement = widget.querySelector('input');
function updateTag(tag, name, color, actionSymbol) {
tag.childNodes[0].textContent = name;
tag.setAttribute("data-value", name);
tag.setAttribute("data-color", color);
tag.setAttribute('style', `background-color: ${color};`);
tag.querySelector('.add-remove-icon').childNodes[0].textContent = actionSymbol;
}
function createNewTag(name, color, actionSymbol) {
const tag = prototype.cloneNode(true);
updateTag(tag, name, color, actionSymbol);
return tag;
}
function addTagCallback(event) {
const tag = event.target;
newTags.removeChild(tag);
updateTag(
tag,
tag.getAttribute("data-value"),
tag.getAttribute("data-color"),
"-"
)
currentTags.appendChild(tag);
tag.removeEventListener('click', addTagCallback);
tag.addEventListener('click', removeTagCallback);
}
function registerNewCallback(tag, newParent, newSymbol, onClicked) {
console.log(tag);
function callback(event) {
tag.parentNode.removeChild(tag);
updateTag(
tag,
tag.getAttribute("data-value"),
tag.getAttribute("data-color"),
newSymbol
);
newParent.appendChild(tag);
tag.removeEventListener('click', callback);
onClicked(tag);
}
tag.addEventListener('click', callback);
}
function updateInputList() {
const names = [];
for (const tag of currentTags.querySelectorAll(".tag")) {
names.push(tag.getAttribute("data-value"));
}
inputElement.value = names.join(",");
}
function addTagCallback(tag) {
registerNewCallback(tag, currentTags, "-", removeTagCallback);
updateInputList();
}
function removeTagCallback(tag) {
registerNewCallback(tag, newTags, "+", addTagCallback);
updateInputList();
}
for (const tag of newTags.querySelectorAll(".tag")) {
addTagCallback(tag);
}
for (const tag of currentTags.querySelectorAll(".tag")) {
removeTagCallback(tag);
}
});
</script>
<input
type="hidden"
name="tags"
value="{% for t in widget.value %}{{ t.name }}{% endfor %}"
>
</div>

45
core/widgets.py Normal file
View File

@ -0,0 +1,45 @@
from typing import Any, Dict, Optional
from django.forms import Widget
class TagsEditor(Widget):
input_type = 'hidden'
template_name = 'core/widget_tag_editor.html'
@staticmethod
def __unpack_tag(tag):
return {'name': tag.name, 'color': tag.color}
def format_value(self, value: Any) -> Optional[str]:
print("FORMAT", value)
if value is not None and not isinstance(value, str):
value = [self.__unpack_tag(tag) for tag in value]
return value
def get_context(self, name: str, value: Any, attrs) -> Dict[str, Any]:
from . import models
most_tags = models.BabyBuddyTag.objects.order_by(
'-last_used'
).all()[:256]
quick_suggestion_tags = models.BabyBuddyTag.objects.order_by(
'-last_used'
).all()
result = super().get_context(name, value, attrs)
tag_names = set(x['name'] for x in result['widget']['value'])
quick_suggestion_tags = [
t for t in quick_suggestion_tags
if t.name not in tag_names
][:5]
result['widget']['tag_suggestions'] = {
'quick': [
self.__unpack_tag(t) for t in quick_suggestion_tags
if t.name not in tag_names
],
'most': [
self.__unpack_tag(t) for t in most_tags
]
}
return result

9968
package-lock.json generated

File diff suppressed because it is too large Load Diff