mirror of https://github.com/snachodog/mybuddy.git
commit
33005b930e
1
Pipfile
1
Pipfile
|
@ -22,6 +22,7 @@ python-dotenv = "*"
|
|||
pyyaml = "*"
|
||||
uritemplate = "*"
|
||||
whitenoise = "*"
|
||||
django-taggit = "==2.1.0"
|
||||
|
||||
[dev-packages]
|
||||
coveralls = "*"
|
||||
|
|
|
@ -6,6 +6,8 @@ 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 +127,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(required=False)
|
||||
|
||||
|
||||
class SleepSerializer(CoreModelWithDurationSerializer):
|
||||
|
@ -196,3 +200,14 @@ class BMISerializer(CoreModelSerializer):
|
|||
class Meta:
|
||||
model = models.BMI
|
||||
fields = ("id", "child", "bmi", "date", "notes")
|
||||
|
||||
|
||||
class TagsSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class Meta:
|
||||
model = models.Tag
|
||||
fields = ("slug", "name", "color", "last_used")
|
||||
extra_kwargs = {
|
||||
"slug": {"required": False, "read_only": True},
|
||||
"color": {"required": False},
|
||||
"last_used": {"required": False, "read_only": True},
|
||||
}
|
||||
|
|
56
api/tests.py
56
api/tests.py
|
@ -251,13 +251,14 @@ class NoteAPITestCase(TestBase.BabyBuddyAPITestCaseBase):
|
|||
def test_get(self):
|
||||
response = self.client.get(self.endpoint)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
self.assertDictEqual(
|
||||
response.data["results"][0],
|
||||
{
|
||||
"id": 1,
|
||||
"child": 1,
|
||||
"note": "Fake note.",
|
||||
"time": "2017-11-17T22:45:00-05:00",
|
||||
"tags": [],
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -551,3 +552,56 @@ class WeightAPITestCase(TestBase.BabyBuddyAPITestCaseBase):
|
|||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data, entry)
|
||||
|
||||
|
||||
class TagsAPITestCase(TestBase.BabyBuddyAPITestCaseBase):
|
||||
endpoint = reverse("api:tag-list")
|
||||
model = models.Tag
|
||||
|
||||
def test_get(self):
|
||||
response = self.client.get(self.endpoint)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertDictEqual(
|
||||
dict(response.data["results"][0]),
|
||||
{
|
||||
"name": "a name",
|
||||
"slug": "a-name",
|
||||
"color": "#FF0000",
|
||||
"last_used": "2017-11-18T11:00:00-05:00",
|
||||
},
|
||||
)
|
||||
|
||||
def test_post(self):
|
||||
data = {"name": "new tag", "color": "#123456"}
|
||||
response = self.client.post(self.endpoint, data, format="json")
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
response = self.client.get(self.endpoint)
|
||||
results = response.json()["results"]
|
||||
results_by_name = {r["name"]: r for r in results}
|
||||
|
||||
tag_data = results_by_name["new tag"]
|
||||
self.assertDictContainsSubset(data, tag_data)
|
||||
self.assertEqual(tag_data["slug"], "new-tag")
|
||||
self.assertTrue(tag_data["last_used"])
|
||||
|
||||
def test_patch(self):
|
||||
endpoint = f"{self.endpoint}a-name/"
|
||||
|
||||
modified_data = {
|
||||
"name": "A different name",
|
||||
"color": "#567890",
|
||||
}
|
||||
response = self.client.patch(
|
||||
endpoint,
|
||||
modified_data,
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertDictContainsSubset(modified_data, response.data)
|
||||
|
||||
def test_delete(self):
|
||||
endpoint = f"{self.endpoint}a-name/"
|
||||
response = self.client.delete(endpoint)
|
||||
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
|
||||
response = self.client.delete(endpoint)
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
|
|
@ -18,6 +18,7 @@ router.register(r"weight", views.WeightViewSet)
|
|||
router.register(r"height", views.HeightViewSet)
|
||||
router.register(r"head-circumference", views.HeadCircumferenceViewSet)
|
||||
router.register(r"bmi", views.BMIViewSet)
|
||||
router.register(r"tags", views.TagsViewSet)
|
||||
|
||||
app_name = "api"
|
||||
|
||||
|
|
|
@ -92,3 +92,10 @@ class BMIViewSet(viewsets.ModelViewSet):
|
|||
queryset = models.BMI.objects.all()
|
||||
serializer_class = serializers.BMISerializer
|
||||
filterset_fields = ("child", "date")
|
||||
|
||||
|
||||
class TagsViewSet(viewsets.ModelViewSet):
|
||||
queryset = models.Tag.objects.all()
|
||||
serializer_class = serializers.TagsSerializer
|
||||
lookup_field = "slug"
|
||||
filterset_fields = ("last_used", "name")
|
||||
|
|
|
@ -462,5 +462,16 @@
|
|||
"date": "2017-11-18",
|
||||
"notes": "before feed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "core.tag",
|
||||
"pk": 1,
|
||||
"fields":
|
||||
{
|
||||
"name": "a name",
|
||||
"slug": "a-name",
|
||||
"color": "#FF0000",
|
||||
"last_used": "2017-11-18T16:00:00Z"
|
||||
}
|
||||
}
|
||||
]
|
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 4.0.3 on 2022-03-05 11:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("babybuddy", "0020_update_language_en_to_en_us"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="settings",
|
||||
name="language",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("zh-hans", "Chinese (simplified)"),
|
||||
("nl", "Dutch"),
|
||||
("en-US", "English (US)"),
|
||||
("en-GB", "English (UK)"),
|
||||
("fr", "French"),
|
||||
("fi", "Finnish"),
|
||||
("de", "German"),
|
||||
("it", "Italian"),
|
||||
("pl", "Polish"),
|
||||
("pt", "Portuguese"),
|
||||
("es", "Spanish"),
|
||||
("sv", "Swedish"),
|
||||
("tr", "Turkish"),
|
||||
],
|
||||
default="en-US",
|
||||
max_length=255,
|
||||
verbose_name="Language",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,354 @@
|
|||
(function() {
|
||||
/**
|
||||
* Parse a string as hexadecimal number
|
||||
*/
|
||||
function hexParse(x) {
|
||||
return parseInt(x, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the contrasting color for any hex color
|
||||
*
|
||||
* Sourced from: https://vanillajstoolkit.com/helpers/getcontrast/
|
||||
* - Modified with slightly softer colors
|
||||
* (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com
|
||||
* Derived from work by Brian Suda, https://24ways.org/2010/calculating-color-contrast/
|
||||
* @param {String} A hexcolor value
|
||||
* @return {String} The contrasting color (black or white)
|
||||
*/
|
||||
function computeComplementaryColor(hexcolor) {
|
||||
|
||||
// If a leading # is provided, remove it
|
||||
if (hexcolor.slice(0, 1) === '#') {
|
||||
hexcolor = hexcolor.slice(1);
|
||||
}
|
||||
|
||||
// If a three-character hexcode, make six-character
|
||||
if (hexcolor.length === 3) {
|
||||
hexcolor = hexcolor.split('').map(function (hex) {
|
||||
return hex + hex;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Convert to RGB value
|
||||
let r = parseInt(hexcolor.substr(0,2),16);
|
||||
let g = parseInt(hexcolor.substr(2,2),16);
|
||||
let b = parseInt(hexcolor.substr(4,2),16);
|
||||
|
||||
// Get YIQ ratio
|
||||
let yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
||||
|
||||
// Check contrast
|
||||
return (yiq >= 128) ? '#101010' : '#EFEFEF';
|
||||
}
|
||||
|
||||
// CSRF token should always be present because it is auto-included with
|
||||
// every tag-editor widget
|
||||
const CSRF_TOKEN = document.querySelector('input[name="csrfmiddlewaretoken"]').value;
|
||||
|
||||
function doReq(method, uri, data, success, fail) {
|
||||
// TODO: prefer jQuery based requests for now
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.addEventListener('load', () => {
|
||||
if ((req.status >= 200) && (req.status < 300)) {
|
||||
success(req.responseText, req);
|
||||
} else {
|
||||
fail(req.responseText, req);
|
||||
}
|
||||
});
|
||||
for (const name of ["error", "timeout", "abort"]) {
|
||||
req.addEventListener(name, () => {
|
||||
fail(req.responseText, req);
|
||||
});
|
||||
}
|
||||
req.timeout = 20000;
|
||||
|
||||
req.open(method, uri);
|
||||
req.setRequestHeader("Content-Type", "application/json");
|
||||
req.setRequestHeader("Accept", "application/json");
|
||||
req.setRequestHeader("X-CSRFTOKEN", CSRF_TOKEN);
|
||||
req.send(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class allowing generic operations on the tag lists, like:
|
||||
*
|
||||
* - Adding tags to a tag list
|
||||
* - Updating or creating new tags with a set name and color
|
||||
* - Controlling the error modal
|
||||
*/
|
||||
class TaggingBase {
|
||||
constructor(widget) {
|
||||
this.prototype = widget.querySelector('.prototype-tag');
|
||||
this.listeners = [];
|
||||
|
||||
this.modalElement = widget.querySelector('.tag-editor-error-modal');
|
||||
this.modalBodyNode = this.modalElement.querySelector('.modal-body');
|
||||
|
||||
// Clean whitespace text nodes between spans
|
||||
for (const n of this.modalBodyNode.childNodes) {
|
||||
if (n.nodeType === Node.TEXT_NODE) {
|
||||
this.modalBodyNode.removeChild(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showModal(msg) {
|
||||
const selectedMessage = this.modalBodyNode.querySelector(`span[data-message='${msg}']`);
|
||||
if (!selectedMessage) {
|
||||
selectedMessage = this.modalBodyNode.childNodes[0];
|
||||
}
|
||||
|
||||
for (const n of this.modalBodyNode.childNodes) {
|
||||
n.classList.add('d-none');
|
||||
}
|
||||
selectedMessage.classList.remove('d-none');
|
||||
|
||||
jQuery(this.modalElement).modal('show');
|
||||
}
|
||||
|
||||
addTagListUpdatedListener(c) {
|
||||
this.listeners.push(c);
|
||||
}
|
||||
|
||||
callTagListUpdatedListeners() {
|
||||
for (const l of this.listeners) {
|
||||
l();
|
||||
}
|
||||
}
|
||||
|
||||
updateTag(tag, name, color, actionSymbol) {
|
||||
const actionTextNode = tag.querySelector('.add-remove-icon').childNodes[0];
|
||||
|
||||
name = name || tag.getAttribute("data-value");
|
||||
color = color || tag.getAttribute("data-color");
|
||||
actionSymbol = actionSymbol || actionTextNode.textContent;
|
||||
|
||||
tag.childNodes[0].textContent = name;
|
||||
tag.setAttribute("data-value", name);
|
||||
tag.setAttribute("data-color", color);
|
||||
|
||||
const textColor = computeComplementaryColor(color);
|
||||
tag.setAttribute('style', `background-color: ${color}; color: ${textColor};`);
|
||||
actionTextNode.textContent = actionSymbol;
|
||||
}
|
||||
|
||||
createNewTag(name, color, actionSymbol) {
|
||||
const tag = this.prototype.cloneNode(true);
|
||||
tag.classList.remove("prototype-tag");
|
||||
tag.classList.add("tag");
|
||||
this.updateTag(tag, name, color, actionSymbol);
|
||||
return tag;
|
||||
}
|
||||
|
||||
insertTag(list, tag) {
|
||||
list.appendChild(tag);
|
||||
this.callTagListUpdatedListeners();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for the edit field allowing to dynamically create new tags.
|
||||
*
|
||||
* Handles user inputs for the editor. Calls the 'onInsertNewTag' callback
|
||||
* when the creation of a new tag has been requested. All backend handling
|
||||
* like guareteening that the requested tag exists is handled by this class,
|
||||
* the only task left is to add the new tag to the tags-list when
|
||||
* 'onInsertNewTag' is called.
|
||||
*/
|
||||
class AddNewTagControl {
|
||||
/**
|
||||
* @param widget
|
||||
* The root DOM element of the widget
|
||||
* @param taggingBase
|
||||
* Reference to a common TaggingBase class to be used by this widget
|
||||
* @param onInsertNewTag
|
||||
* Callback that is called when a new tag should be added to the
|
||||
* tags widget.
|
||||
*/
|
||||
constructor(widget, taggingBase, onInsertNewTag) {
|
||||
this.widget = widget;
|
||||
this.taggingBase = taggingBase;
|
||||
|
||||
this.apiTagsUrl = widget.getAttribute('data-tags-url');
|
||||
this.createTagInputs = widget.querySelector('.create-tag-inputs');
|
||||
this.addTagInput = this.createTagInputs.querySelector('input[type="text"]');
|
||||
this.addTagButton = this.createTagInputs.querySelector('.btn-add-new-tag');
|
||||
|
||||
this.addTagInput.value = "";
|
||||
|
||||
this.onInsertNewTag = onInsertNewTag;
|
||||
|
||||
this.addTagButton.addEventListener('click', () => this.onCreateTagClicked());
|
||||
this.addTagInput.addEventListener('keydown', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === "enter") {
|
||||
e.preventDefault();
|
||||
this.onCreateTagClicked();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback called when the the "Add" button of the add-tag input is
|
||||
* clicked or enter is pressed in the editor.
|
||||
*/
|
||||
onCreateTagClicked() {
|
||||
// TODO: Make promise based
|
||||
|
||||
const tagName = this.addTagInput.value.trim();
|
||||
const uriTagName = encodeURIComponent(tagName);
|
||||
|
||||
const fail = (msg) => {
|
||||
this.addTagInput.select();
|
||||
this.taggingBase.showModal(msg || "generic");
|
||||
};
|
||||
|
||||
if (!tagName) {
|
||||
fail('invalid-tag-name');
|
||||
return;
|
||||
}
|
||||
|
||||
const addTag = (name, color) => {
|
||||
const tag = this.taggingBase.createNewTag(name, color, "-");
|
||||
this.addTagInput.value = "";
|
||||
this.onInsertNewTag(tag);
|
||||
};
|
||||
|
||||
const data = JSON.stringify({
|
||||
'name': this.addTagInput.value
|
||||
});
|
||||
|
||||
doReq("GET", `${this.apiTagsUrl}?name=${uriTagName}`, null,
|
||||
(text) => {
|
||||
const json = JSON.parse(text);
|
||||
if (json.count) {
|
||||
const tagJson = json.results[0];
|
||||
addTag(tagJson.name, tagJson.color);
|
||||
} else {
|
||||
doReq("POST", this.apiTagsUrl, data,
|
||||
(text) => {
|
||||
const tagJson = JSON.parse(text);
|
||||
addTag(tagJson.name, tagJson.color);
|
||||
}, () => fail("tag-creation-failed")
|
||||
);
|
||||
}
|
||||
}, () => fail("tag-checking-failed")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* JavaScript implementation for the tags editor.
|
||||
*
|
||||
* This class uses TaggingBase and AddNewTagControl to provide the custom
|
||||
* tag editor controls. This mainly consists of updating the hidden
|
||||
* input values with the current list of tags and adding/removing
|
||||
* tags from the current-tags- or recently-used-lists.
|
||||
*/
|
||||
class TagsEditor {
|
||||
/**
|
||||
* @param tagEditorRoot
|
||||
* The root DOM element of the widget.
|
||||
*/
|
||||
constructor(tagEditorRoot) {
|
||||
this.widget = tagEditorRoot;
|
||||
this.taggingBase = new TaggingBase(this.widget);
|
||||
this.addTagControl = new AddNewTagControl(
|
||||
this.widget, this.taggingBase, (t) => this.insertNewTag(t)
|
||||
);
|
||||
|
||||
this.currentTags = this.widget.querySelector('.current_tags');
|
||||
this.newTags = this.widget.querySelector('.new-tags');
|
||||
this.inputElement = this.widget.querySelector('input[type="hidden"]');
|
||||
|
||||
for (const tag of this.newTags.querySelectorAll(".tag")) {
|
||||
this.configureAddTag(tag);
|
||||
}
|
||||
for (const tag of this.currentTags.querySelectorAll(".tag")) {
|
||||
this.configureRemoveTag(tag);
|
||||
}
|
||||
|
||||
this.updateInputList();
|
||||
this.taggingBase.addTagListUpdatedListener(
|
||||
() => this.updateInputList()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new tag into the "current tag" list.
|
||||
*
|
||||
* Makes sure that no duplicates are present in the widget before adding
|
||||
* the new tag. If a duplicate is found, the old tag is removed before
|
||||
* the new one is added.
|
||||
*/
|
||||
insertNewTag(tag) {
|
||||
const name = tag.getAttribute("data-value");
|
||||
|
||||
const oldTag = this.widget.querySelector(`span[data-value="${name}"]`);
|
||||
if (oldTag) {
|
||||
oldTag.parentNode.removeChild(oldTag);
|
||||
}
|
||||
|
||||
this.taggingBase.insertTag(this.currentTags, tag);
|
||||
this.configureRemoveTag(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registeres a click-callback for a given node.
|
||||
*
|
||||
* The callback chain-calls another callback "onClicked" after
|
||||
* moving the clicked tag from the old tag-list to a new tag list.
|
||||
*/
|
||||
registerNewCallback(tag, newParent, onClicked) {
|
||||
function callback(event) {
|
||||
tag.parentNode.removeChild(tag);
|
||||
this.taggingBase.insertTag(newParent, tag);
|
||||
|
||||
tag.removeEventListener('click', callback);
|
||||
onClicked(tag);
|
||||
}
|
||||
tag.addEventListener('click', callback.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the value of the hidden input element.
|
||||
*
|
||||
* Sets the value from the list of tags added to the currentTags
|
||||
* DOM element.
|
||||
*/
|
||||
updateInputList() {
|
||||
const names = [];
|
||||
for (const tag of this.currentTags.querySelectorAll(".tag")) {
|
||||
const name = tag.getAttribute("data-value");
|
||||
names.push(`"${name}"`);
|
||||
}
|
||||
this.inputElement.value = names.join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a tag-DOM element as a "add tag" button.
|
||||
*/
|
||||
configureAddTag(tag) {
|
||||
this.taggingBase.updateTag(tag, null, null, "+");
|
||||
this.registerNewCallback(tag, this.currentTags, () => this.configureRemoveTag(tag));
|
||||
this.updateInputList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a tag-DOM element as a "remove tag" button.
|
||||
*/
|
||||
configureRemoveTag(tag) {
|
||||
this.taggingBase.updateTag(tag, null, null, "-");
|
||||
this.registerNewCallback(tag, this.newTags, () => this.configureAddTag(tag));
|
||||
this.updateInputList();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
for (const el of document.querySelectorAll('.babybuddy-tags-editor')) {
|
||||
new TagsEditor(el);
|
||||
}
|
||||
});
|
||||
})();
|
|
@ -52,3 +52,8 @@
|
|||
.icon-2x {
|
||||
font-size: 1.65em;
|
||||
}
|
||||
|
||||
// All modals
|
||||
.modal-content {
|
||||
color: theme-color('dark');
|
||||
}
|
|
@ -1,9 +1,12 @@
|
|||
{% load i18n widget_tweaks %}
|
||||
|
||||
{# Load any form-javascript files #}
|
||||
{{ form.media.js }}
|
||||
<div class="container-fluid pb-5">
|
||||
<form role="form" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{% for field in form %}
|
||||
{{ field.widget }}
|
||||
<div class="form-group row">
|
||||
{% include 'babybuddy/form_field.html' %}
|
||||
</div>
|
||||
|
|
|
@ -6,6 +6,7 @@ from import_export import fields, resources
|
|||
from import_export.admin import ImportExportMixin, ExportActionMixin
|
||||
|
||||
from core import models
|
||||
from core.forms import TagAdminForm
|
||||
|
||||
|
||||
class ImportExportResourceBase(resources.ModelResource):
|
||||
|
@ -177,3 +178,17 @@ class WeightAdmin(ImportExportMixin, ExportActionMixin, admin.ModelAdmin):
|
|||
"weight",
|
||||
)
|
||||
resource_class = WeightImportExportResource
|
||||
|
||||
|
||||
class TaggedItemInline(admin.StackedInline):
|
||||
model = models.Tagged
|
||||
|
||||
|
||||
@admin.register(models.Tag)
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
form = TagAdminForm
|
||||
inlines = [TaggedItemInline]
|
||||
list_display = ["name", "slug", "color", "last_used"]
|
||||
ordering = ["name", "slug"]
|
||||
search_fields = ["name"]
|
||||
prepopulated_fields = {"slug": ["name"]}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from django import forms
|
||||
from django.forms import widgets
|
||||
from django.conf import settings
|
||||
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):
|
||||
|
@ -82,6 +84,7 @@ class CoreModelForm(forms.ModelForm):
|
|||
timer.stop(instance.end)
|
||||
if commit:
|
||||
instance.save()
|
||||
self.save_m2m()
|
||||
return instance
|
||||
|
||||
|
||||
|
@ -161,7 +164,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 +172,7 @@ class NoteForm(CoreModelForm):
|
|||
"data-target": "#datetimepicker_time",
|
||||
}
|
||||
),
|
||||
"tags": TagsEditor(),
|
||||
}
|
||||
|
||||
|
||||
|
@ -310,3 +314,8 @@ class BMIForm(CoreModelForm):
|
|||
),
|
||||
"notes": forms.Textarea(attrs={"rows": 5}),
|
||||
}
|
||||
|
||||
|
||||
class TagAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
widgets = {"color": widgets.TextInput(attrs={"type": "color"})}
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
# Generated by Django 4.0.3 on 2022-03-08 11:51
|
||||
|
||||
import core.models
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("core", "0018_bmi_headcircumference_height"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Tag",
|
||||
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(
|
||||
default=core.models.random_color,
|
||||
max_length=32,
|
||||
validators=[
|
||||
django.core.validators.RegexValidator("^#[0-9a-fA-F]{6}$")
|
||||
],
|
||||
verbose_name="Color",
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_used",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="Last used"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Tags",
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Tagged",
|
||||
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.tag",
|
||||
verbose_name="Tag",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="note",
|
||||
name="tags",
|
||||
field=core.models.TaggableManager(
|
||||
blank=True,
|
||||
help_text="Click on the tags to add (+) or remove (-) tags or use the text editor to create new tags.",
|
||||
through="core.Tagged",
|
||||
to="core.Tag",
|
||||
verbose_name="Tags",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,5 +1,6 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from datetime import timedelta
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
@ -9,6 +10,14 @@ 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.core.validators import RegexValidator
|
||||
|
||||
import random
|
||||
|
||||
from taggit.managers import TaggableManager as TaggitTaggableManager
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
random.seed()
|
||||
|
||||
|
||||
def validate_date(date, field_name):
|
||||
|
@ -71,6 +80,78 @@ def validate_time(time, field_name):
|
|||
)
|
||||
|
||||
|
||||
def random_color():
|
||||
TAG_COLORS = [
|
||||
"#ff0000",
|
||||
"#00ff00",
|
||||
"#0000ff",
|
||||
"#ff00ff",
|
||||
"#ffff00",
|
||||
"#00ffff",
|
||||
"#ff7f7f",
|
||||
"#7fff7f",
|
||||
"#7f7fff",
|
||||
"#ff7fff",
|
||||
"#ffff7f",
|
||||
"#7fffff",
|
||||
"#7f0000",
|
||||
"#007f00",
|
||||
"#00007f",
|
||||
"#7f007f",
|
||||
"#7f7f00",
|
||||
"#007f7f",
|
||||
]
|
||||
return TAG_COLORS[random.randrange(0, len(TAG_COLORS))]
|
||||
|
||||
|
||||
class Tag(TagBase):
|
||||
class Meta:
|
||||
verbose_name = _("Tags")
|
||||
|
||||
color = models.CharField(
|
||||
verbose_name=_("Color"),
|
||||
max_length=32,
|
||||
default=random_color,
|
||||
validators=[RegexValidator(r"^#[0-9a-fA-F]{6}$")],
|
||||
)
|
||||
|
||||
last_used = models.DateTimeField(
|
||||
verbose_name=_("Last used"),
|
||||
default=timezone.now,
|
||||
blank=False,
|
||||
)
|
||||
|
||||
|
||||
class Tagged(GenericTaggedItemBase):
|
||||
tag = models.ForeignKey(
|
||||
Tag,
|
||||
verbose_name=_("Tag"),
|
||||
on_delete=models.CASCADE,
|
||||
related_name="%(app_label)s_%(class)s_items",
|
||||
)
|
||||
|
||||
def save_base(self, *args, **kwargs):
|
||||
"""
|
||||
Update last_used of the used tag, whenever it is used in a
|
||||
save-operation.
|
||||
"""
|
||||
self.tag.last_used = timezone.now()
|
||||
self.tag.save()
|
||||
return super().save_base(*args, **kwargs)
|
||||
|
||||
|
||||
class TaggableManager(TaggitTaggableManager):
|
||||
"""
|
||||
Replace the default help_text with
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["help_text"] = _(
|
||||
"Click on the tags to add (+) or remove (-) tags or use the text editor to create new tags."
|
||||
)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class Child(models.Model):
|
||||
model_name = "child"
|
||||
first_name = models.CharField(max_length=255, verbose_name=_("First name"))
|
||||
|
@ -251,6 +332,7 @@ class Note(models.Model):
|
|||
time = models.DateTimeField(
|
||||
default=timezone.now, blank=False, verbose_name=_("Time")
|
||||
)
|
||||
tags = TaggableManager(blank=True, through=Tagged)
|
||||
|
||||
objects = models.Manager()
|
||||
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
{% load i18n %}
|
||||
|
||||
<div data-tags-url="{% url 'api:api-root' %}tags/"
|
||||
{% for k, v in widget.attrs.items %}
|
||||
{{ k }}="{{ v }}"
|
||||
{% endfor %}>
|
||||
{% csrf_token %}
|
||||
<span class="prototype-tag btn badge badge-pill cursor-pointer mr-1" style="display: none;">
|
||||
UNINITIALIZED 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 mr-1" style="background-color: {{ t.color }};">
|
||||
{{ t.name }}
|
||||
<span class="add-remove-icon pl-1 pr-1">-</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="new-tags">
|
||||
<div class="create-tag-inputs input-group">
|
||||
<input class="form-control" type="text" name="" placeholder="{% trans "Tag name" %}">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-primary bg-dark btn-add-new-tag" type="button">{% trans "Add" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
<span>{% trans "Recently used:" %}</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 mr-1" style="background-color: {{ t.color }};">
|
||||
{{ t.name }}
|
||||
<span class="add-remove-icon pl-1 pr-1">+</span>
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input
|
||||
type="hidden"
|
||||
name="{{ widget.name }}"
|
||||
value="{% for t in widget.value %}"{{ t.name }}"{% if not forloop.last %},{% endif %}{% endfor %}"
|
||||
>
|
||||
|
||||
<div class="modal fade tag-editor-error-modal">
|
||||
<div class="modal-dialog modal-sm" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{% trans "Error" context "Error modal" %}</h4>
|
||||
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<span data-message="generic">{% trans "An error ocurred." context "Error modal" %}</span>
|
||||
<span data-message="invalid-tag-name">{% trans "Invalid tag name." context "Error modal" %}</span>
|
||||
<span data-message="tag-creation-failed">{% trans "Failed to create tag." context "Error modal" %}</span>
|
||||
<span data-message="tag-checking-failed">{% trans "Failed to obtain tag data." context "Error modal" %}</span>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-danger" data-dismiss="modal">
|
||||
{% trans "Close" context "Error modal" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,4 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from datetime import datetime
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
@ -602,3 +603,105 @@ class WeightFormsTest(FormsTestCaseBase):
|
|||
page = self.c.post("/weight/{}/delete/".format(self.weight.id), follow=True)
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertContains(page, "Weight entry deleted")
|
||||
|
||||
|
||||
class NotesFormsTest(FormsTestCaseBase):
|
||||
"""
|
||||
Piggy-backs a bunch of tests for the tags-logic.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(NotesFormsTest, cls).setUpClass()
|
||||
|
||||
cls.note = models.Note.objects.create(
|
||||
child=cls.child,
|
||||
note="Setup note",
|
||||
time=timezone.now() - timezone.timedelta(days=2),
|
||||
)
|
||||
cls.note.tags.add("oldtag")
|
||||
cls.oldtag = models.Tag.objects.filter(slug="oldtag").first()
|
||||
|
||||
def test_add_no_tags(self):
|
||||
params = {
|
||||
"child": self.child.id,
|
||||
"note": "note with no tags",
|
||||
"time": (timezone.now() - timezone.timedelta(minutes=1)).isoformat(),
|
||||
}
|
||||
|
||||
page = self.c.post("/notes/add/", params, follow=True)
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertContains(page, "note with no tags")
|
||||
|
||||
def test_add_with_tags(self):
|
||||
params = {
|
||||
"child": self.child.id,
|
||||
"note": "this note has tags",
|
||||
"time": (timezone.now() - timezone.timedelta(minutes=1)).isoformat(),
|
||||
"tags": 'A,B,"setup tag"',
|
||||
}
|
||||
|
||||
old_notes = list(models.Note.objects.all())
|
||||
|
||||
page = self.c.post("/notes/add/", params, follow=True)
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertContains(page, "this note has tags")
|
||||
|
||||
new_notes = list(models.Note.objects.all())
|
||||
|
||||
# Find the new tag and extract its tags
|
||||
old_pks = [n.pk for n in old_notes]
|
||||
new_note = [n for n in new_notes if n.pk not in old_pks][0]
|
||||
new_note_tag_names = [t.name for t in new_note.tags.all()]
|
||||
|
||||
self.assertSetEqual(set(new_note_tag_names), {"A", "B", "setup tag"})
|
||||
|
||||
def test_edit(self):
|
||||
old_tag_last_used = self.oldtag.last_used
|
||||
|
||||
params = {
|
||||
"child": self.note.child.id,
|
||||
"note": "Edited note",
|
||||
"time": self.localdate_string(),
|
||||
"tags": "oldtag,newtag",
|
||||
}
|
||||
page = self.c.post("/notes/{}/".format(self.note.id), params, follow=True)
|
||||
self.assertEqual(page.status_code, 200)
|
||||
|
||||
self.note.refresh_from_db()
|
||||
self.oldtag.refresh_from_db()
|
||||
self.assertEqual(self.note.note, params["note"])
|
||||
self.assertContains(
|
||||
page, "Note entry for {} updated".format(str(self.note.child))
|
||||
)
|
||||
|
||||
self.assertSetEqual(
|
||||
set(t.name for t in self.note.tags.all()), {"oldtag", "newtag"}
|
||||
)
|
||||
|
||||
# Old tag remains old, because it was not added
|
||||
self.assertEqual(old_tag_last_used, self.oldtag.last_used)
|
||||
|
||||
# Second phase: Remove all tags then add "oldtag" through posting
|
||||
# which should update the last_used tag
|
||||
self.note.tags.clear()
|
||||
self.note.save()
|
||||
|
||||
params = {
|
||||
"child": self.note.child.id,
|
||||
"note": "Edited note (2)",
|
||||
"time": self.localdate_string(),
|
||||
"tags": "oldtag",
|
||||
}
|
||||
page = self.c.post("/notes/{}/".format(self.note.id), params, follow=True)
|
||||
self.assertEqual(page.status_code, 200)
|
||||
|
||||
self.note.refresh_from_db()
|
||||
self.oldtag.refresh_from_db()
|
||||
|
||||
self.assertLess(old_tag_last_used, self.oldtag.last_used)
|
||||
|
||||
def test_delete(self):
|
||||
page = self.c.post("/notes/{}/delete/".format(self.note.id), follow=True)
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertContains(page, "Note entry deleted")
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
from django.forms import Media
|
||||
from typing import Any, Dict, Optional
|
||||
from django.forms import Widget
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class TagsEditor(Widget):
|
||||
"""
|
||||
Custom widget that provides an alternative editor for tags provided by the
|
||||
taggit library.
|
||||
|
||||
The widget makes use of bootstrap v4 and its badge/pill feature and renders
|
||||
a list of tags as badges that can be clicked to remove or add a tag to
|
||||
the list of set tags. In addition, a user can dynamically add new, custom
|
||||
tags, using a text editor.
|
||||
"""
|
||||
|
||||
class Media:
|
||||
js = ("babybuddy/js/tags_editor.js",)
|
||||
|
||||
input_type = "hidden"
|
||||
template_name = "core/widget_tag_editor.html"
|
||||
|
||||
@staticmethod
|
||||
def __unpack_tag(tag: models.Tag):
|
||||
"""
|
||||
Tiny utility function used to translate a tag to a serializable
|
||||
dictionary of strings.
|
||||
"""
|
||||
return {"name": tag.name, "color": tag.color}
|
||||
|
||||
def format_value(self, value: Any) -> Optional[str]:
|
||||
"""
|
||||
Override format_value to provide a list of dictionaries rather than
|
||||
a flat, comma-separated list of tags. This allows for the more
|
||||
complex rendering of tags provided by this plugin.
|
||||
"""
|
||||
if value is not None and not isinstance(value, str):
|
||||
value = [self.__unpack_tag(tag) for tag in value]
|
||||
return value
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
"""
|
||||
Bootstrap integration adds form-control to the classes of the widget.
|
||||
This works only for "plain" input-based widgets however. In addition,
|
||||
we need to add a custom class "babybuddy-tags-editor" for the javascript
|
||||
file to detect the widget and take control of its contents.
|
||||
"""
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
class_string = attrs.get("class", "")
|
||||
class_string = class_string.replace("form-control", "")
|
||||
attrs["class"] = class_string + " babybuddy-tags-editor"
|
||||
return attrs
|
||||
|
||||
def get_context(self, name: str, value: Any, attrs) -> Dict[str, Any]:
|
||||
"""
|
||||
Adds extra information to the payload provided to the widget's template.
|
||||
|
||||
Specifically:
|
||||
- Query a list if "recently used" tags (max 256 to not cause
|
||||
DoS issues) from the database to be used for auto-completion. ("most")
|
||||
- Query a smaller list of 5 tags to be made available from a quick
|
||||
selection widget ("quick").
|
||||
"""
|
||||
most_tags = models.Tag.objects.order_by("-last_used").all()[:256]
|
||||
|
||||
result = super().get_context(name, value, attrs)
|
||||
|
||||
tag_names = set(
|
||||
x["name"] for x in (result.get("widget", {}).get("value", None) or [])
|
||||
)
|
||||
quick_suggestion_tags = [t for t in most_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
|
|
@ -68,6 +68,9 @@ module.exports = {
|
|||
'api/static_src/js/*.js',
|
||||
'core/static_src/js/*.js',
|
||||
'dashboard/static_src/js/*.js'
|
||||
],
|
||||
tags_editor: [
|
||||
'babybuddy/static_src/js/tags_editor.js'
|
||||
]
|
||||
},
|
||||
stylesConfig: {
|
||||
|
|
|
@ -199,6 +199,12 @@ function scripts(cb) {
|
|||
concat('app.js'),
|
||||
gulp.dest(config.scriptsConfig.dest)
|
||||
], cb);
|
||||
|
||||
pump([
|
||||
gulp.src(config.scriptsConfig.tags_editor),
|
||||
concat('tags_editor.js'),
|
||||
gulp.dest(config.scriptsConfig.dest)
|
||||
], cb);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Binary file not shown.
|
@ -2,7 +2,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: Baby Buddy\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2022-02-25 05:14+0000\n"
|
||||
"POT-Creation-Date: 2022-03-02 21:15+0000\n"
|
||||
"Language: de\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
|
@ -121,59 +121,59 @@ msgstr "Zeitzone"
|
|||
msgid "{user}'s Settings"
|
||||
msgstr "{user} Einstellungen"
|
||||
|
||||
#: babybuddy/settings/base.py:166
|
||||
#: babybuddy/settings/base.py:167
|
||||
msgid "Chinese (simplified)"
|
||||
msgstr ""
|
||||
|
||||
#: babybuddy/settings/base.py:167
|
||||
#: babybuddy/settings/base.py:168
|
||||
msgid "Dutch"
|
||||
msgstr ""
|
||||
|
||||
#: babybuddy/settings/base.py:168
|
||||
#: babybuddy/settings/base.py:169
|
||||
#, fuzzy
|
||||
#| msgid "English"
|
||||
msgid "English (US)"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: babybuddy/settings/base.py:169
|
||||
#: babybuddy/settings/base.py:170
|
||||
#, fuzzy
|
||||
#| msgid "English"
|
||||
msgid "English (UK)"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: babybuddy/settings/base.py:170
|
||||
#: babybuddy/settings/base.py:171
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: babybuddy/settings/base.py:171
|
||||
#: babybuddy/settings/base.py:172
|
||||
msgid "Finnish"
|
||||
msgstr ""
|
||||
|
||||
#: babybuddy/settings/base.py:172
|
||||
#: babybuddy/settings/base.py:173
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
#: babybuddy/settings/base.py:173
|
||||
#: babybuddy/settings/base.py:174
|
||||
msgid "Italian"
|
||||
msgstr ""
|
||||
|
||||
#: babybuddy/settings/base.py:174
|
||||
#: babybuddy/settings/base.py:175
|
||||
msgid "Polish"
|
||||
msgstr ""
|
||||
|
||||
#: babybuddy/settings/base.py:175
|
||||
#: babybuddy/settings/base.py:176
|
||||
msgid "Portuguese"
|
||||
msgstr ""
|
||||
|
||||
#: babybuddy/settings/base.py:176
|
||||
#: babybuddy/settings/base.py:177
|
||||
msgid "Spanish"
|
||||
msgstr "Spanisch"
|
||||
|
||||
#: babybuddy/settings/base.py:177
|
||||
#: babybuddy/settings/base.py:178
|
||||
msgid "Swedish"
|
||||
msgstr "Schwedisch"
|
||||
|
||||
#: babybuddy/settings/base.py:178
|
||||
#: babybuddy/settings/base.py:179
|
||||
msgid "Turkish"
|
||||
msgstr "Türkisch"
|
||||
|
||||
|
@ -199,7 +199,7 @@ msgstr "Reset"
|
|||
msgid "Filters"
|
||||
msgstr "Filter"
|
||||
|
||||
#: babybuddy/templates/babybuddy/form.html:11
|
||||
#: babybuddy/templates/babybuddy/form.html:14
|
||||
#: babybuddy/templates/babybuddy/user_settings_form.html:89
|
||||
msgid "Submit"
|
||||
msgstr "Senden"
|
||||
|
@ -222,27 +222,27 @@ msgstr ""
|
|||
msgid "Quick Start Timer"
|
||||
msgstr "Quick-Start Timer"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:51 core/models.py:158
|
||||
#: core/models.py:162
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:51 core/models.py:232
|
||||
#: core/models.py:236
|
||||
msgid "Diaper Change"
|
||||
msgstr "Windeln wechseln"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:57
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:274 core/models.py:227
|
||||
#: core/models.py:231 core/templates/core/timer_detail.html:43
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:274 core/models.py:301
|
||||
#: core/models.py:305 core/templates/core/timer_detail.html:43
|
||||
msgid "Feeding"
|
||||
msgstr "Mahlzeit"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:63
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:150 core/models.py:250
|
||||
#: core/models.py:260 core/models.py:264 core/templates/core/note_list.html:29
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:150 core/models.py:324
|
||||
#: core/models.py:335 core/models.py:339 core/templates/core/note_list.html:29
|
||||
msgid "Note"
|
||||
msgstr "Notiz"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:69
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:281
|
||||
#: babybuddy/templates/babybuddy/welcome.html:42 core/models.py:292
|
||||
#: core/models.py:293 core/models.py:296
|
||||
#: babybuddy/templates/babybuddy/welcome.html:42 core/models.py:367
|
||||
#: core/models.py:368 core/models.py:371
|
||||
#: core/templates/core/sleep_confirm_delete.html:7
|
||||
#: core/templates/core/sleep_form.html:13 core/templates/core/sleep_list.html:4
|
||||
#: core/templates/core/sleep_list.html:7 core/templates/core/sleep_list.html:12
|
||||
|
@ -251,8 +251,8 @@ msgid "Sleep"
|
|||
msgstr "Schlafen"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:75
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:172 core/models.py:331
|
||||
#: core/models.py:341 core/models.py:342 core/models.py:345
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:172 core/models.py:406
|
||||
#: core/models.py:416 core/models.py:417 core/models.py:420
|
||||
#: core/templates/core/temperature_confirm_delete.html:7
|
||||
#: core/templates/core/temperature_form.html:13
|
||||
#: core/templates/core/temperature_list.html:4
|
||||
|
@ -264,8 +264,8 @@ msgstr "Temperatur"
|
|||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:81
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:294
|
||||
#: babybuddy/templates/babybuddy/welcome.html:50 core/models.py:468
|
||||
#: core/models.py:469 core/models.py:472
|
||||
#: babybuddy/templates/babybuddy/welcome.html:50 core/models.py:543
|
||||
#: core/models.py:544 core/models.py:547
|
||||
#: core/templates/core/timer_detail.html:59
|
||||
#: core/templates/core/tummytime_confirm_delete.html:7
|
||||
#: core/templates/core/tummytime_form.html:13
|
||||
|
@ -276,16 +276,16 @@ msgid "Tummy Time"
|
|||
msgstr "Bauchzeit"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:87
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:186 core/models.py:494
|
||||
#: core/models.py:503 core/models.py:504 core/models.py:507
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:186 core/models.py:569
|
||||
#: core/models.py:578 core/models.py:579 core/models.py:582
|
||||
#: core/templates/core/weight_confirm_delete.html:7
|
||||
#: core/templates/core/weight_form.html:13
|
||||
#: core/templates/core/weight_list.html:4
|
||||
#: core/templates/core/weight_list.html:7
|
||||
#: core/templates/core/weight_list.html:12
|
||||
#: core/templates/core/weight_list.html:29
|
||||
#: dashboard/templates/dashboard/child_button_group.html:31
|
||||
#: reports/graphs/weight_weight.py:19 reports/graphs/weight_weight.py:30
|
||||
#: core/templates/core/weight_list.html:29 reports/graphs/weight_weight.py:19
|
||||
#: reports/graphs/weight_weight.py:30
|
||||
#: reports/templates/reports/report_list.html:21
|
||||
#: reports/templates/reports/weight_change.html:4
|
||||
#: reports/templates/reports/weight_change.html:8
|
||||
msgid "Weight"
|
||||
|
@ -299,20 +299,20 @@ msgid "Timeline"
|
|||
msgstr ""
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:122
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:130 core/models.py:101
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:130 core/models.py:175
|
||||
#: core/templates/core/child_confirm_delete.html:7
|
||||
#: core/templates/core/child_detail.html:7
|
||||
#: core/templates/core/child_form.html:13 core/templates/core/child_list.html:4
|
||||
#: core/templates/core/child_list.html:7 core/templates/core/child_list.html:12
|
||||
#: dashboard/templates/dashboard/child.html:7
|
||||
#: reports/templates/reports/report_base.html:7
|
||||
#: reports/templates/reports/base.html:7
|
||||
msgid "Children"
|
||||
msgstr "Kinder"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:136 core/models.py:100
|
||||
#: core/models.py:134 core/models.py:190 core/models.py:248 core/models.py:276
|
||||
#: core/models.py:328 core/models.py:359 core/models.py:452 core/models.py:492
|
||||
#: core/models.py:519 core/models.py:546 core/models.py:572
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:136 core/models.py:174
|
||||
#: core/models.py:208 core/models.py:264 core/models.py:322 core/models.py:351
|
||||
#: core/models.py:403 core/models.py:434 core/models.py:527 core/models.py:567
|
||||
#: core/models.py:594 core/models.py:621 core/models.py:647
|
||||
#: core/templates/core/bmi_list.html:27
|
||||
#: core/templates/core/diaperchange_list.html:27
|
||||
#: core/templates/core/feeding_list.html:27
|
||||
|
@ -326,9 +326,9 @@ msgstr "Kinder"
|
|||
msgid "Child"
|
||||
msgstr "Kind"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:144 core/models.py:151
|
||||
#: core/models.py:220 core/models.py:261 core/models.py:284 core/models.py:334
|
||||
#: core/models.py:496 core/models.py:523 core/models.py:552 core/models.py:576
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:144 core/models.py:225
|
||||
#: core/models.py:294 core/models.py:336 core/models.py:359 core/models.py:409
|
||||
#: core/models.py:571 core/models.py:598 core/models.py:627 core/models.py:651
|
||||
#: core/templates/core/note_confirm_delete.html:7
|
||||
#: core/templates/core/note_form.html:13 core/templates/core/note_list.html:4
|
||||
#: core/templates/core/note_list.html:7 core/templates/core/note_list.html:12
|
||||
|
@ -347,18 +347,18 @@ msgstr "Temperatur Messung"
|
|||
msgid "Weight entry"
|
||||
msgstr "Gewichtseintrag"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:200 core/models.py:521
|
||||
#: core/models.py:530 core/models.py:531 core/models.py:534
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:200 core/models.py:596
|
||||
#: core/models.py:605 core/models.py:606 core/models.py:609
|
||||
#: core/templates/core/height_confirm_delete.html:7
|
||||
#: core/templates/core/height_form.html:13
|
||||
#: core/templates/core/height_list.html:4
|
||||
#: core/templates/core/height_list.html:7
|
||||
#: core/templates/core/height_list.html:12
|
||||
#: core/templates/core/height_list.html:29
|
||||
#: dashboard/templates/dashboard/child_button_group.html:32
|
||||
#: reports/graphs/height_height.py:19 reports/graphs/height_height.py:30
|
||||
#: core/templates/core/height_list.html:29 reports/graphs/height_height.py:19
|
||||
#: reports/graphs/height_height.py:30
|
||||
#: reports/templates/reports/height_change.html:4
|
||||
#: reports/templates/reports/height_change.html:8
|
||||
#: reports/templates/reports/report_list.html:17
|
||||
#, fuzzy
|
||||
#| msgid "Weight"
|
||||
msgid "Height"
|
||||
|
@ -370,38 +370,36 @@ msgstr "Gewicht"
|
|||
msgid "Height entry"
|
||||
msgstr "Gewichtseintrag"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:214 core/models.py:549
|
||||
#: core/models.py:559 core/models.py:560 core/models.py:563
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:214 core/models.py:624
|
||||
#: core/models.py:634 core/models.py:635 core/models.py:638
|
||||
#: core/templates/core/head_circumference_confirm_delete.html:7
|
||||
#: core/templates/core/head_circumference_form.html:13
|
||||
#: core/templates/core/head_circumference_list.html:4
|
||||
#: core/templates/core/head_circumference_list.html:7
|
||||
#: core/templates/core/head_circumference_list.html:12
|
||||
#: core/templates/core/head_circumference_list.html:29
|
||||
#: dashboard/templates/dashboard/child_button_group.html:33
|
||||
#: reports/graphs/head_circumference_head_circumference.py:19
|
||||
#: reports/graphs/head_circumference_head_circumference.py:30
|
||||
#: reports/templates/reports/head_circumference_change.html:4
|
||||
#: reports/templates/reports/head_circumference_change.html:8
|
||||
#: reports/templates/reports/report_list.html:16
|
||||
msgid "Head Circumference"
|
||||
msgstr ""
|
||||
msgstr "Kopfumfang"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:220
|
||||
msgid "Head Circumference entry"
|
||||
msgstr ""
|
||||
msgstr "Kopfumfang Eintrag"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:228 core/models.py:574
|
||||
#: core/models.py:583 core/models.py:584 core/models.py:587
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:228 core/models.py:649
|
||||
#: core/models.py:658 core/models.py:659 core/models.py:662
|
||||
#: core/templates/core/bmi_confirm_delete.html:7
|
||||
#: core/templates/core/bmi_form.html:13 core/templates/core/bmi_list.html:4
|
||||
#: core/templates/core/bmi_list.html:7 core/templates/core/bmi_list.html:12
|
||||
#: core/templates/core/bmi_list.html:29
|
||||
#: dashboard/templates/dashboard/child_button_group.html:34
|
||||
#: reports/graphs/bmi_bmi.py:19 reports/graphs/bmi_bmi.py:30
|
||||
#: reports/templates/reports/bmi_change.html:4
|
||||
#: core/templates/core/bmi_list.html:29 reports/graphs/bmi_bmi.py:19
|
||||
#: reports/graphs/bmi_bmi.py:30 reports/templates/reports/bmi_change.html:4
|
||||
#: reports/templates/reports/bmi_change.html:8
|
||||
msgid "BMI"
|
||||
msgstr ""
|
||||
msgstr "BMI"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:234
|
||||
#, fuzzy
|
||||
|
@ -423,7 +421,7 @@ msgid "Change"
|
|||
msgstr "Wechsel"
|
||||
|
||||
#: babybuddy/templates/babybuddy/nav-dropdown.html:268
|
||||
#: babybuddy/templates/babybuddy/welcome.html:34 core/models.py:228
|
||||
#: babybuddy/templates/babybuddy/welcome.html:34 core/models.py:302
|
||||
#: core/templates/core/feeding_confirm_delete.html:7
|
||||
#: core/templates/core/feeding_form.html:13
|
||||
#: core/templates/core/feeding_list.html:4
|
||||
|
@ -443,7 +441,7 @@ msgstr "Bauchzeit-Eintrag"
|
|||
#: babybuddy/templates/babybuddy/nav-dropdown.html:325
|
||||
#: babybuddy/templates/babybuddy/user_list.html:17
|
||||
#: babybuddy/templates/babybuddy/user_password_form.html:7
|
||||
#: babybuddy/templates/babybuddy/user_settings_form.html:7 core/models.py:378
|
||||
#: babybuddy/templates/babybuddy/user_settings_form.html:7 core/models.py:453
|
||||
#: core/templates/core/timer_list.html:32
|
||||
msgid "User"
|
||||
msgstr "Benutzer"
|
||||
|
@ -535,7 +533,7 @@ msgstr "Benutzer löschen"
|
|||
#: core/templates/core/tummytime_confirm_delete.html:17
|
||||
#: core/templates/core/weight_confirm_delete.html:8
|
||||
#: core/templates/core/weight_confirm_delete.html:17
|
||||
#: dashboard/templates/dashboard/child_button_group.html:48
|
||||
#: dashboard/templates/dashboard/child_button_group.html:27
|
||||
msgid "Delete"
|
||||
msgstr "löschen"
|
||||
|
||||
|
@ -631,7 +629,7 @@ msgstr "E-Mail"
|
|||
msgid "Staff"
|
||||
msgstr "Angestellte"
|
||||
|
||||
#: babybuddy/templates/babybuddy/user_list.html:22 core/models.py:373
|
||||
#: babybuddy/templates/babybuddy/user_list.html:22 core/models.py:448
|
||||
#: core/templates/core/timer_list.html:31
|
||||
msgid "Active"
|
||||
msgstr "Aktiv"
|
||||
|
@ -708,7 +706,7 @@ msgstr ""
|
|||
"Lerne und sehe die Bedürfnisse deines Babys voraus, ohne (<em>allzu viel</"
|
||||
"em>)Spekulation indem du Baby Buddy verwendest —"
|
||||
|
||||
#: babybuddy/templates/babybuddy/welcome.html:26 core/models.py:159
|
||||
#: babybuddy/templates/babybuddy/welcome.html:26 core/models.py:233
|
||||
#: core/templates/core/diaperchange_confirm_delete.html:7
|
||||
#: core/templates/core/diaperchange_form.html:13
|
||||
#: core/templates/core/diaperchange_list.html:4
|
||||
|
@ -894,105 +892,113 @@ msgstr "User API-Key neu generiert."
|
|||
msgid "Settings saved!"
|
||||
msgstr "Einstellungen gespeichert!"
|
||||
|
||||
#: core/forms.py:115
|
||||
#: core/forms.py:117
|
||||
msgid "Name does not match child name."
|
||||
msgstr "Name entspricht nicht dem Kindernamen."
|
||||
|
||||
#: core/models.py:23
|
||||
#: core/models.py:32
|
||||
msgid "Date can not be in the future."
|
||||
msgstr "Datum darf nicht in der Zukunft liegen."
|
||||
|
||||
#: core/models.py:37
|
||||
#: core/models.py:46
|
||||
msgid "Start time must come before end time."
|
||||
msgstr "Startzeit muss vor Endzeit sein."
|
||||
|
||||
#: core/models.py:40
|
||||
#: core/models.py:49
|
||||
msgid "Duration too long."
|
||||
msgstr "Dauer zu lange."
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:65
|
||||
msgid "Another entry intersects the specified time period."
|
||||
msgstr "Ein anderer Eintrag schneidet sich mit der angegebenen Zeitperiode."
|
||||
|
||||
#: core/models.py:70
|
||||
#: core/models.py:79
|
||||
msgid "Date/time can not be in the future."
|
||||
msgstr "Datum/Zeit darf nicht in der Zukunft liegen."
|
||||
|
||||
#: core/models.py:76
|
||||
#: core/models.py:109
|
||||
msgid "Tag"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:110
|
||||
msgid "Tags"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:150
|
||||
msgid "First name"
|
||||
msgstr "Vorname"
|
||||
|
||||
#: core/models.py:78
|
||||
#: core/models.py:152
|
||||
msgid "Last name"
|
||||
msgstr "Nachname"
|
||||
|
||||
#: core/models.py:80
|
||||
#: core/models.py:154
|
||||
msgid "Birth date"
|
||||
msgstr "Geburtsdatum"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:161
|
||||
msgid "Slug"
|
||||
msgstr "Slug"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:164
|
||||
msgid "Picture"
|
||||
msgstr "Bild"
|
||||
|
||||
#: core/models.py:136 core/models.py:252 core/models.py:333
|
||||
#: core/models.py:210 core/models.py:326 core/models.py:408
|
||||
#: core/templates/core/diaperchange_list.html:25
|
||||
#: core/templates/core/note_list.html:25
|
||||
#: core/templates/core/temperature_list.html:25
|
||||
msgid "Time"
|
||||
msgstr "Zeit"
|
||||
|
||||
#: core/models.py:137 core/templates/core/diaperchange_list.html:60
|
||||
#: core/models.py:211 core/templates/core/diaperchange_list.html:60
|
||||
#: reports/graphs/diaperchange_types.py:36
|
||||
msgid "Wet"
|
||||
msgstr "Nass"
|
||||
|
||||
#: core/models.py:138 core/templates/core/diaperchange_list.html:61
|
||||
#: core/models.py:212 core/templates/core/diaperchange_list.html:61
|
||||
#: reports/graphs/diaperchange_types.py:30
|
||||
msgid "Solid"
|
||||
msgstr "Fest"
|
||||
|
||||
#: core/models.py:142
|
||||
#: core/models.py:216
|
||||
msgid "Black"
|
||||
msgstr "Schwarz"
|
||||
|
||||
#: core/models.py:143
|
||||
#: core/models.py:217
|
||||
msgid "Brown"
|
||||
msgstr "Braun"
|
||||
|
||||
#: core/models.py:144
|
||||
#: core/models.py:218
|
||||
msgid "Green"
|
||||
msgstr "Grün"
|
||||
|
||||
#: core/models.py:145
|
||||
#: core/models.py:219
|
||||
msgid "Yellow"
|
||||
msgstr "Gelb"
|
||||
|
||||
#: core/models.py:148 core/templates/core/diaperchange_list.html:30
|
||||
#: core/models.py:222 core/templates/core/diaperchange_list.html:30
|
||||
msgid "Color"
|
||||
msgstr "Farbe"
|
||||
|
||||
#: core/models.py:150 core/models.py:219
|
||||
#: core/models.py:224 core/models.py:293
|
||||
#: core/templates/core/diaperchange_list.html:31
|
||||
msgid "Amount"
|
||||
msgstr "Menge"
|
||||
|
||||
#: core/models.py:180
|
||||
#: core/models.py:254
|
||||
msgid "Wet and/or solid is required."
|
||||
msgstr "Nass und/oder fest wird benötigt."
|
||||
|
||||
#: core/models.py:192 core/models.py:279 core/models.py:365 core/models.py:454
|
||||
#: core/models.py:266 core/models.py:354 core/models.py:440 core/models.py:529
|
||||
msgid "Start time"
|
||||
msgstr "Startzeit"
|
||||
|
||||
#: core/models.py:193 core/models.py:280 core/models.py:368 core/models.py:455
|
||||
#: core/models.py:267 core/models.py:355 core/models.py:443 core/models.py:530
|
||||
msgid "End time"
|
||||
msgstr "Endzeit"
|
||||
|
||||
#: core/models.py:195 core/models.py:282 core/models.py:371 core/models.py:457
|
||||
#: core/models.py:269 core/models.py:357 core/models.py:446 core/models.py:532
|
||||
#: core/templates/core/feeding_list.html:34
|
||||
#: core/templates/core/sleep_list.html:30
|
||||
#: core/templates/core/timer_list.html:29
|
||||
|
@ -1000,67 +1006,67 @@ msgstr "Endzeit"
|
|||
msgid "Duration"
|
||||
msgstr "Dauer"
|
||||
|
||||
#: core/models.py:199
|
||||
#: core/models.py:273
|
||||
msgid "Breast milk"
|
||||
msgstr "Brustmilch"
|
||||
|
||||
#: core/models.py:200
|
||||
#: core/models.py:274
|
||||
msgid "Formula"
|
||||
msgstr "Formel"
|
||||
|
||||
#: core/models.py:201
|
||||
#: core/models.py:275
|
||||
msgid "Fortified breast milk"
|
||||
msgstr "Angereicherte Brustmilch"
|
||||
|
||||
#: core/models.py:202
|
||||
#: core/models.py:276
|
||||
msgid "Solid food"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:205 core/templates/core/feeding_list.html:30
|
||||
#: core/models.py:279 core/templates/core/feeding_list.html:30
|
||||
msgid "Type"
|
||||
msgstr "Typ"
|
||||
|
||||
#: core/models.py:209
|
||||
#: core/models.py:283
|
||||
msgid "Bottle"
|
||||
msgstr "Fläschchen"
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:284
|
||||
msgid "Left breast"
|
||||
msgstr "Linke Brust"
|
||||
|
||||
#: core/models.py:211
|
||||
#: core/models.py:285
|
||||
msgid "Right breast"
|
||||
msgstr "Rechte Brust"
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:286
|
||||
msgid "Both breasts"
|
||||
msgstr "Beide Brüste"
|
||||
|
||||
#: core/models.py:213
|
||||
#: core/models.py:287
|
||||
msgid "Parent fed"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:214
|
||||
#: core/models.py:288
|
||||
msgid "Self fed"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:217 core/templates/core/feeding_list.html:29
|
||||
#: core/models.py:291 core/templates/core/feeding_list.html:29
|
||||
msgid "Method"
|
||||
msgstr "Methode"
|
||||
|
||||
#: core/models.py:278
|
||||
#: core/models.py:353
|
||||
msgid "Napping"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:362 core/templates/core/timer_list.html:25
|
||||
#: core/models.py:437 core/templates/core/timer_list.html:25
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
#: core/models.py:386 core/templates/core/timer_form.html:4
|
||||
#: core/models.py:461 core/templates/core/timer_form.html:4
|
||||
msgid "Timer"
|
||||
msgstr "Timer"
|
||||
|
||||
#: core/models.py:387 core/templates/core/timer_confirm_delete.html:9
|
||||
#: core/models.py:462 core/templates/core/timer_confirm_delete.html:9
|
||||
#: core/templates/core/timer_confirm_delete_inactive.html:9
|
||||
#: core/templates/core/timer_detail.html:8
|
||||
#: core/templates/core/timer_form.html:7 core/templates/core/timer_list.html:4
|
||||
|
@ -1069,16 +1075,16 @@ msgstr "Timer"
|
|||
msgid "Timers"
|
||||
msgstr "Timer"
|
||||
|
||||
#: core/models.py:390
|
||||
#: core/models.py:465
|
||||
#, python-brace-format
|
||||
msgid "Timer #{id}"
|
||||
msgstr "Timer #{id}"
|
||||
|
||||
#: core/models.py:460 core/templates/core/tummytime_list.html:30
|
||||
#: core/models.py:535 core/templates/core/tummytime_list.html:30
|
||||
msgid "Milestone"
|
||||
msgstr "Meilenstein"
|
||||
|
||||
#: core/models.py:495 core/models.py:522 core/models.py:551 core/models.py:575
|
||||
#: core/models.py:570 core/models.py:597 core/models.py:626 core/models.py:650
|
||||
#: core/templates/core/bmi_list.html:25
|
||||
#: core/templates/core/feeding_list.html:25
|
||||
#: core/templates/core/head_circumference_list.html:25
|
||||
|
@ -1098,24 +1104,22 @@ msgstr "Datum"
|
|||
#, fuzzy
|
||||
#| msgid "Delete a Sleep Entry"
|
||||
msgid "Delete a BMI Entry"
|
||||
msgstr "Einen Schlaf-Eintrag löschen"
|
||||
msgstr "Einen BMI-Wert löschen"
|
||||
|
||||
#: core/templates/core/bmi_form.html:8 core/templates/core/bmi_form.html:17
|
||||
#: core/templates/core/bmi_form.html:27
|
||||
#, fuzzy
|
||||
#| msgid "Add a Sleep Entry"
|
||||
msgid "Add a BMI Entry"
|
||||
msgstr "Schlaf-Eintrag hinzufügen"
|
||||
msgstr "BMI-Wert hinzufügen"
|
||||
|
||||
#: core/templates/core/bmi_list.html:15
|
||||
msgid "Add BMI"
|
||||
msgstr ""
|
||||
msgstr "BMI Wert hinzufügen"
|
||||
|
||||
#: core/templates/core/bmi_list.html:66
|
||||
#, fuzzy
|
||||
#| msgid "No timer entries found."
|
||||
msgid "No bmi entries found."
|
||||
msgstr "Keine Timer-Einträge gefunden."
|
||||
msgstr "Keine BMI-Einträge gefunden."
|
||||
|
||||
#: core/templates/core/child_confirm_delete.html:4
|
||||
msgid "Delete a Child"
|
||||
|
@ -1165,6 +1169,7 @@ msgstr "Windelwechsel hinzufügen"
|
|||
#: core/templates/core/feeding_form.html:17
|
||||
#: core/templates/core/note_form.html:17 core/templates/core/sleep_form.html:17
|
||||
#: core/templates/core/tummytime_form.html:17
|
||||
#: core/templates/core/widget_tag_editor.html:24
|
||||
msgid "Add"
|
||||
msgstr "Hinzufügen"
|
||||
|
||||
|
@ -1206,34 +1211,28 @@ msgid "No feedings found."
|
|||
msgstr "Keine Mahlzeit gefunden."
|
||||
|
||||
#: core/templates/core/head_circumference_confirm_delete.html:4
|
||||
#, fuzzy
|
||||
#| msgid "Delete a Tummy Time Entry"
|
||||
msgid "Delete a Head Circumference Entry"
|
||||
msgstr "Bauchzeit-Eintrag löschen"
|
||||
msgstr "Kopfumfang löschen"
|
||||
|
||||
#: core/templates/core/head_circumference_form.html:8
|
||||
#: core/templates/core/head_circumference_form.html:17
|
||||
#: core/templates/core/head_circumference_form.html:27
|
||||
#, fuzzy
|
||||
#| msgid "Add a Temperature Entry"
|
||||
msgid "Add a Head Circumference Entry"
|
||||
msgstr "Temperaturmessung hinzufügen"
|
||||
msgstr "Kopfumfang hinzufügen"
|
||||
|
||||
#: core/templates/core/head_circumference_list.html:15
|
||||
msgid "Add Head Circumference"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/core/head_circumference_list.html:66
|
||||
#, fuzzy
|
||||
#| msgid "No timer entries found."
|
||||
msgid "No head circumference entries found."
|
||||
msgstr "Keine Timer-Einträge gefunden."
|
||||
msgstr "Keine Kopfumfang-Einträge gefunden."
|
||||
|
||||
#: core/templates/core/height_confirm_delete.html:4
|
||||
#, fuzzy
|
||||
#| msgid "Delete a Weight Entry"
|
||||
msgid "Delete a Height Entry"
|
||||
msgstr "Gewichts-Eintrag löschen"
|
||||
msgstr "Größen-Eintrag löschen"
|
||||
|
||||
#: core/templates/core/height_form.html:8
|
||||
#: core/templates/core/height_form.html:17
|
||||
|
@ -1241,19 +1240,17 @@ msgstr "Gewichts-Eintrag löschen"
|
|||
#, fuzzy
|
||||
#| msgid "Add a Weight Entry"
|
||||
msgid "Add a Height Entry"
|
||||
msgstr "Gewichts-Eintrag hinzufügen"
|
||||
msgstr "Größen-Eintrag hinzufügen"
|
||||
|
||||
#: core/templates/core/height_list.html:15
|
||||
#, fuzzy
|
||||
#| msgid "Add Weight"
|
||||
msgid "Add Height"
|
||||
msgstr "Gewicht hinzufügen"
|
||||
msgstr "Größe hinzufügen"
|
||||
|
||||
#: core/templates/core/height_list.html:66
|
||||
#, fuzzy
|
||||
#| msgid "No weight entries found."
|
||||
msgid "No height entries found."
|
||||
msgstr "Keine Gewichts-Einträge gefunden."
|
||||
msgstr "Keine Größen-Einträge gefunden."
|
||||
|
||||
#: core/templates/core/note_confirm_delete.html:4
|
||||
msgid "Delete a Note"
|
||||
|
@ -1450,6 +1447,44 @@ msgstr "Gewicht hinzufügen"
|
|||
msgid "No weight entries found."
|
||||
msgstr "Keine Gewichts-Einträge gefunden."
|
||||
|
||||
#: core/templates/core/widget_tag_editor.html:22
|
||||
msgid "Tag name"
|
||||
msgstr "Tag-Name"
|
||||
|
||||
#: core/templates/core/widget_tag_editor.html:27
|
||||
msgid "Recently used:"
|
||||
msgstr "Kürzlich verwendet:"
|
||||
|
||||
#: core/templates/core/widget_tag_editor.html:45
|
||||
msgctxt "Error modal"
|
||||
msgid "Error"
|
||||
msgstr "Fehler"
|
||||
|
||||
#: core/templates/core/widget_tag_editor.html:50
|
||||
msgctxt "Error modal"
|
||||
msgid "An error ocurred."
|
||||
msgstr "Ein Fehler ist aufgetreten."
|
||||
|
||||
#: core/templates/core/widget_tag_editor.html:51
|
||||
msgctxt "Error modal"
|
||||
msgid "Invalid tag name."
|
||||
msgstr "Ungültiger Tag-Name."
|
||||
|
||||
#: core/templates/core/widget_tag_editor.html:52
|
||||
msgctxt "Error modal"
|
||||
msgid "Failed to create tag."
|
||||
msgstr "Fehler bein erzeugen des tags."
|
||||
|
||||
#: core/templates/core/widget_tag_editor.html:53
|
||||
msgctxt "Error modal"
|
||||
msgid "Failed to obtain tag data."
|
||||
msgstr "Konnte Tag nicht laden."
|
||||
|
||||
#: core/templates/core/widget_tag_editor.html:58
|
||||
msgctxt "Error modal"
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
#: core/templates/timeline/_timeline.html:33
|
||||
#, python-format
|
||||
msgid "%(since)s ago (%(time)s)"
|
||||
|
@ -1467,7 +1502,7 @@ msgid "%(since)s since previous"
|
|||
msgstr ""
|
||||
|
||||
#: core/templates/timeline/_timeline.html:56
|
||||
#: dashboard/templates/dashboard/child_button_group.html:41
|
||||
#: dashboard/templates/dashboard/child_button_group.html:20
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
|
@ -1730,53 +1765,12 @@ msgstr "Nie"
|
|||
msgid "Child actions"
|
||||
msgstr "Aktionen des Kindes"
|
||||
|
||||
#: dashboard/templates/dashboard/child_button_group.html:17
|
||||
#: reports/templates/reports/report_base.html:9
|
||||
#: dashboard/templates/dashboard/child_button_group.html:12
|
||||
#: reports/templates/reports/base.html:9
|
||||
#: reports/templates/reports/report_list.html:4
|
||||
msgid "Reports"
|
||||
msgstr "Reports"
|
||||
|
||||
#: dashboard/templates/dashboard/child_button_group.html:23
|
||||
msgid "Diaper Change Amounts"
|
||||
msgstr "Windelwechsel Mengen"
|
||||
|
||||
#: dashboard/templates/dashboard/child_button_group.html:24
|
||||
#: reports/templates/reports/diaperchange_types.html:4
|
||||
#: reports/templates/reports/diaperchange_types.html:8
|
||||
msgid "Diaper Change Types"
|
||||
msgstr "Windewechsel Typen"
|
||||
|
||||
#: dashboard/templates/dashboard/child_button_group.html:25
|
||||
#: reports/templates/reports/diaperchange_lifetimes.html:4
|
||||
#: reports/templates/reports/diaperchange_lifetimes.html:8
|
||||
msgid "Diaper Lifetimes"
|
||||
msgstr "Windel-Lebensdauer"
|
||||
|
||||
#: dashboard/templates/dashboard/child_button_group.html:26
|
||||
#: reports/templates/reports/feeding_amounts.html:4
|
||||
#: reports/templates/reports/feeding_amounts.html:8
|
||||
msgid "Feeding Amounts"
|
||||
msgstr "Mahlzeiten"
|
||||
|
||||
#: dashboard/templates/dashboard/child_button_group.html:27
|
||||
msgid "Feeding Durations (Average)"
|
||||
msgstr "Mahlzeit Dauer (Durschschnitt)"
|
||||
|
||||
#: dashboard/templates/dashboard/child_button_group.html:28
|
||||
#: reports/templates/reports/sleep_pattern.html:4
|
||||
#: reports/templates/reports/sleep_pattern.html:8
|
||||
msgid "Sleep Pattern"
|
||||
msgstr "Schlafrhythmus"
|
||||
|
||||
#: dashboard/templates/dashboard/child_button_group.html:29
|
||||
#: reports/templates/reports/sleep_totals.html:4
|
||||
#: reports/templates/reports/sleep_totals.html:8
|
||||
msgid "Sleep Totals"
|
||||
msgstr "Schlaf Total"
|
||||
|
||||
#: dashboard/templates/dashboard/child_button_group.html:30
|
||||
msgid "Tummy Time Durations (Sum)"
|
||||
msgstr ""
|
||||
|
||||
#: dashboard/templatetags/cards.py:288
|
||||
msgid "Diaper change frequency"
|
||||
msgstr "Frequenz Windelwechsel"
|
||||
|
@ -1817,7 +1811,7 @@ msgstr "Gewichtsänderung pro Woche"
|
|||
#, fuzzy
|
||||
#| msgid "Weight change per week"
|
||||
msgid "BMI change per week"
|
||||
msgstr "Gewichtsänderung pro Woche"
|
||||
msgstr "BMI-änderung pro Woche"
|
||||
|
||||
#: dashboard/templatetags/cards.py:418
|
||||
msgid "Feeding frequency (past 3 days)"
|
||||
|
@ -1835,7 +1829,7 @@ msgstr "Freuqenz Mahlzeiten"
|
|||
#, fuzzy
|
||||
#| msgid "<b>Weight</b>"
|
||||
msgid "<b>BMI</b>"
|
||||
msgstr "<b>Gewicht</b>"
|
||||
msgstr "<b>BMI</b>"
|
||||
|
||||
#: reports/graphs/diaperchange_amounts.py:27
|
||||
msgid "Diaper change amount"
|
||||
|
@ -1955,15 +1949,61 @@ msgstr "<b>Gewicht</b>"
|
|||
msgid "Diaper Amounts"
|
||||
msgstr "Windel Mengen"
|
||||
|
||||
#: reports/templates/reports/diaperchange_lifetimes.html:4
|
||||
#: reports/templates/reports/diaperchange_lifetimes.html:8
|
||||
#: reports/templates/reports/report_list.html:13
|
||||
msgid "Diaper Lifetimes"
|
||||
msgstr "Windel-Lebensdauer"
|
||||
|
||||
#: reports/templates/reports/diaperchange_types.html:4
|
||||
#: reports/templates/reports/diaperchange_types.html:8
|
||||
#: reports/templates/reports/report_list.html:12
|
||||
msgid "Diaper Change Types"
|
||||
msgstr "Windewechsel Typen"
|
||||
|
||||
#: reports/templates/reports/feeding_amounts.html:4
|
||||
#: reports/templates/reports/feeding_amounts.html:8
|
||||
#: reports/templates/reports/report_list.html:14
|
||||
msgid "Feeding Amounts"
|
||||
msgstr "Mahlzeiten"
|
||||
|
||||
#: reports/templates/reports/feeding_duration.html:4
|
||||
#: reports/templates/reports/feeding_duration.html:8
|
||||
msgid "Average Feeding Durations"
|
||||
msgstr "Durchschnittliche Mahlzeitendauer"
|
||||
|
||||
#: reports/templates/reports/report_base.html:19
|
||||
#: reports/templates/reports/report_base.html:17
|
||||
msgid "There is not enough data to generate this report."
|
||||
msgstr "Es gibt nicht genügend Daten um diesen Report zu generieren."
|
||||
|
||||
#: reports/templates/reports/report_list.html:10
|
||||
msgid "Body Mass Index (BMI)"
|
||||
msgstr "Body Mass Index (BMI)"
|
||||
|
||||
#: reports/templates/reports/report_list.html:11
|
||||
msgid "Diaper Change Amounts"
|
||||
msgstr "Windelwechsel Mengen"
|
||||
|
||||
#: reports/templates/reports/report_list.html:15
|
||||
msgid "Feeding Durations (Average)"
|
||||
msgstr "Mahlzeit Dauer (Durschschnitt)"
|
||||
|
||||
#: reports/templates/reports/report_list.html:18
|
||||
#: reports/templates/reports/sleep_pattern.html:4
|
||||
#: reports/templates/reports/sleep_pattern.html:8
|
||||
msgid "Sleep Pattern"
|
||||
msgstr "Schlafrhythmus"
|
||||
|
||||
#: reports/templates/reports/report_list.html:19
|
||||
#: reports/templates/reports/sleep_totals.html:4
|
||||
#: reports/templates/reports/sleep_totals.html:8
|
||||
msgid "Sleep Totals"
|
||||
msgstr "Schlaf Total"
|
||||
|
||||
#: reports/templates/reports/report_list.html:20
|
||||
msgid "Tummy Time Durations (Sum)"
|
||||
msgstr ""
|
||||
|
||||
#: reports/templates/reports/tummytime_duration.html:4
|
||||
#: reports/templates/reports/tummytime_duration.html:8
|
||||
msgid "Total Tummy Time Durations"
|
||||
|
|
|
@ -7,8 +7,9 @@
|
|||
|
||||
-i https://pypi.python.org/simple
|
||||
asgiref==3.5.0; python_version >= '3.7'
|
||||
boto3==1.20.52
|
||||
botocore==1.23.52; python_version >= '3.6'
|
||||
backports.zoneinfo==0.2.1; python_version < '3.9'
|
||||
boto3==1.21.11
|
||||
botocore==1.24.11; python_version >= '3.6'
|
||||
defusedxml==0.7.1; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'
|
||||
diff-match-patch==20200713; python_version >= '2.7'
|
||||
dj-database-url==0.5.0
|
||||
|
@ -19,11 +20,12 @@ django-imagekit==4.1.0
|
|||
django-import-export==2.7.1
|
||||
django-ipware==4.0.2; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'
|
||||
django-storages==1.12.3
|
||||
django-taggit==2.1.0
|
||||
django-widget-tweaks==1.4.12
|
||||
django==4.0.2
|
||||
django==4.0.3
|
||||
djangorestframework==3.13.1
|
||||
et-xmlfile==1.1.0; python_version >= '3.6'
|
||||
faker==12.2.0
|
||||
faker==13.3.0
|
||||
gunicorn==20.1.0
|
||||
jmespath==0.10.0; python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'
|
||||
markuppy==1.14
|
||||
|
@ -37,8 +39,8 @@ python-dateutil==2.8.2; python_version >= '2.7' and python_version not in '3.0,
|
|||
python-dotenv==0.19.2
|
||||
pytz==2021.3
|
||||
pyyaml==6.0
|
||||
s3transfer==0.5.1; python_version >= '3.6'
|
||||
setuptools==60.8.2; python_version >= '3.7'
|
||||
s3transfer==0.5.2; python_version >= '3.6'
|
||||
setuptools==60.9.3; python_version >= '3.7'
|
||||
six==1.16.0; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
|
||||
sqlparse==0.4.2; python_version >= '3.5'
|
||||
tablib[html,ods,xls,xlsx,yaml]==3.2.0; python_version >= '3.7'
|
||||
|
|
|
@ -10586,6 +10586,10 @@ h3 {
|
|||
font-size: 1.65em;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
#view-core\:child .child-photo {
|
||||
max-width: 150px;
|
||||
}
|
Binary file not shown.
|
@ -10586,6 +10586,10 @@ h3 {
|
|||
font-size: 1.65em;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
#view-core\:child .child-photo {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
|
Binary file not shown.
|
@ -0,0 +1,354 @@
|
|||
(function() {
|
||||
/**
|
||||
* Parse a string as hexadecimal number
|
||||
*/
|
||||
function hexParse(x) {
|
||||
return parseInt(x, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the contrasting color for any hex color
|
||||
*
|
||||
* Sourced from: https://vanillajstoolkit.com/helpers/getcontrast/
|
||||
* - Modified with slightly softer colors
|
||||
* (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com
|
||||
* Derived from work by Brian Suda, https://24ways.org/2010/calculating-color-contrast/
|
||||
* @param {String} A hexcolor value
|
||||
* @return {String} The contrasting color (black or white)
|
||||
*/
|
||||
function computeComplementaryColor(hexcolor) {
|
||||
|
||||
// If a leading # is provided, remove it
|
||||
if (hexcolor.slice(0, 1) === '#') {
|
||||
hexcolor = hexcolor.slice(1);
|
||||
}
|
||||
|
||||
// If a three-character hexcode, make six-character
|
||||
if (hexcolor.length === 3) {
|
||||
hexcolor = hexcolor.split('').map(function (hex) {
|
||||
return hex + hex;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Convert to RGB value
|
||||
let r = parseInt(hexcolor.substr(0,2),16);
|
||||
let g = parseInt(hexcolor.substr(2,2),16);
|
||||
let b = parseInt(hexcolor.substr(4,2),16);
|
||||
|
||||
// Get YIQ ratio
|
||||
let yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
||||
|
||||
// Check contrast
|
||||
return (yiq >= 128) ? '#101010' : '#EFEFEF';
|
||||
}
|
||||
|
||||
// CSRF token should always be present because it is auto-included with
|
||||
// every tag-editor widget
|
||||
const CSRF_TOKEN = document.querySelector('input[name="csrfmiddlewaretoken"]').value;
|
||||
|
||||
function doReq(method, uri, data, success, fail) {
|
||||
// TODO: prefer jQuery based requests for now
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.addEventListener('load', () => {
|
||||
if ((req.status >= 200) && (req.status < 300)) {
|
||||
success(req.responseText, req);
|
||||
} else {
|
||||
fail(req.responseText, req);
|
||||
}
|
||||
});
|
||||
for (const name of ["error", "timeout", "abort"]) {
|
||||
req.addEventListener(name, () => {
|
||||
fail(req.responseText, req);
|
||||
});
|
||||
}
|
||||
req.timeout = 20000;
|
||||
|
||||
req.open(method, uri);
|
||||
req.setRequestHeader("Content-Type", "application/json");
|
||||
req.setRequestHeader("Accept", "application/json");
|
||||
req.setRequestHeader("X-CSRFTOKEN", CSRF_TOKEN);
|
||||
req.send(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class allowing generic operations on the tag lists, like:
|
||||
*
|
||||
* - Adding tags to a tag list
|
||||
* - Updating or creating new tags with a set name and color
|
||||
* - Controlling the error modal
|
||||
*/
|
||||
class TaggingBase {
|
||||
constructor(widget) {
|
||||
this.prototype = widget.querySelector('.prototype-tag');
|
||||
this.listeners = [];
|
||||
|
||||
this.modalElement = widget.querySelector('.tag-editor-error-modal');
|
||||
this.modalBodyNode = this.modalElement.querySelector('.modal-body');
|
||||
|
||||
// Clean whitespace text nodes between spans
|
||||
for (const n of this.modalBodyNode.childNodes) {
|
||||
if (n.nodeType === Node.TEXT_NODE) {
|
||||
this.modalBodyNode.removeChild(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showModal(msg) {
|
||||
const selectedMessage = this.modalBodyNode.querySelector(`span[data-message='${msg}']`);
|
||||
if (!selectedMessage) {
|
||||
selectedMessage = this.modalBodyNode.childNodes[0];
|
||||
}
|
||||
|
||||
for (const n of this.modalBodyNode.childNodes) {
|
||||
n.classList.add('d-none');
|
||||
}
|
||||
selectedMessage.classList.remove('d-none');
|
||||
|
||||
jQuery(this.modalElement).modal('show');
|
||||
}
|
||||
|
||||
addTagListUpdatedListener(c) {
|
||||
this.listeners.push(c);
|
||||
}
|
||||
|
||||
callTagListUpdatedListeners() {
|
||||
for (const l of this.listeners) {
|
||||
l();
|
||||
}
|
||||
}
|
||||
|
||||
updateTag(tag, name, color, actionSymbol) {
|
||||
const actionTextNode = tag.querySelector('.add-remove-icon').childNodes[0];
|
||||
|
||||
name = name || tag.getAttribute("data-value");
|
||||
color = color || tag.getAttribute("data-color");
|
||||
actionSymbol = actionSymbol || actionTextNode.textContent;
|
||||
|
||||
tag.childNodes[0].textContent = name;
|
||||
tag.setAttribute("data-value", name);
|
||||
tag.setAttribute("data-color", color);
|
||||
|
||||
const textColor = computeComplementaryColor(color);
|
||||
tag.setAttribute('style', `background-color: ${color}; color: ${textColor};`);
|
||||
actionTextNode.textContent = actionSymbol;
|
||||
}
|
||||
|
||||
createNewTag(name, color, actionSymbol) {
|
||||
const tag = this.prototype.cloneNode(true);
|
||||
tag.classList.remove("prototype-tag");
|
||||
tag.classList.add("tag");
|
||||
this.updateTag(tag, name, color, actionSymbol);
|
||||
return tag;
|
||||
}
|
||||
|
||||
insertTag(list, tag) {
|
||||
list.appendChild(tag);
|
||||
this.callTagListUpdatedListeners();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for the edit field allowing to dynamically create new tags.
|
||||
*
|
||||
* Handles user inputs for the editor. Calls the 'onInsertNewTag' callback
|
||||
* when the creation of a new tag has been requested. All backend handling
|
||||
* like guareteening that the requested tag exists is handled by this class,
|
||||
* the only task left is to add the new tag to the tags-list when
|
||||
* 'onInsertNewTag' is called.
|
||||
*/
|
||||
class AddNewTagControl {
|
||||
/**
|
||||
* @param widget
|
||||
* The root DOM element of the widget
|
||||
* @param taggingBase
|
||||
* Reference to a common TaggingBase class to be used by this widget
|
||||
* @param onInsertNewTag
|
||||
* Callback that is called when a new tag should be added to the
|
||||
* tags widget.
|
||||
*/
|
||||
constructor(widget, taggingBase, onInsertNewTag) {
|
||||
this.widget = widget;
|
||||
this.taggingBase = taggingBase;
|
||||
|
||||
this.apiTagsUrl = widget.getAttribute('data-tags-url');
|
||||
this.createTagInputs = widget.querySelector('.create-tag-inputs');
|
||||
this.addTagInput = this.createTagInputs.querySelector('input[type="text"]');
|
||||
this.addTagButton = this.createTagInputs.querySelector('.btn-add-new-tag');
|
||||
|
||||
this.addTagInput.value = "";
|
||||
|
||||
this.onInsertNewTag = onInsertNewTag;
|
||||
|
||||
this.addTagButton.addEventListener('click', () => this.onCreateTagClicked());
|
||||
this.addTagInput.addEventListener('keydown', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === "enter") {
|
||||
e.preventDefault();
|
||||
this.onCreateTagClicked();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback called when the the "Add" button of the add-tag input is
|
||||
* clicked or enter is pressed in the editor.
|
||||
*/
|
||||
onCreateTagClicked() {
|
||||
// TODO: Make promise based
|
||||
|
||||
const tagName = this.addTagInput.value.trim();
|
||||
const uriTagName = encodeURIComponent(tagName);
|
||||
|
||||
const fail = (msg) => {
|
||||
this.addTagInput.select();
|
||||
this.taggingBase.showModal(msg || "generic");
|
||||
};
|
||||
|
||||
if (!tagName) {
|
||||
fail('invalid-tag-name');
|
||||
return;
|
||||
}
|
||||
|
||||
const addTag = (name, color) => {
|
||||
const tag = this.taggingBase.createNewTag(name, color, "-");
|
||||
this.addTagInput.value = "";
|
||||
this.onInsertNewTag(tag);
|
||||
};
|
||||
|
||||
const data = JSON.stringify({
|
||||
'name': this.addTagInput.value
|
||||
});
|
||||
|
||||
doReq("GET", `${this.apiTagsUrl}?name=${uriTagName}`, null,
|
||||
(text) => {
|
||||
const json = JSON.parse(text);
|
||||
if (json.count) {
|
||||
const tagJson = json.results[0];
|
||||
addTag(tagJson.name, tagJson.color);
|
||||
} else {
|
||||
doReq("POST", this.apiTagsUrl, data,
|
||||
(text) => {
|
||||
const tagJson = JSON.parse(text);
|
||||
addTag(tagJson.name, tagJson.color);
|
||||
}, () => fail("tag-creation-failed")
|
||||
);
|
||||
}
|
||||
}, () => fail("tag-checking-failed")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* JavaScript implementation for the tags editor.
|
||||
*
|
||||
* This class uses TaggingBase and AddNewTagControl to provide the custom
|
||||
* tag editor controls. This mainly consists of updating the hidden
|
||||
* input values with the current list of tags and adding/removing
|
||||
* tags from the current-tags- or recently-used-lists.
|
||||
*/
|
||||
class TagsEditor {
|
||||
/**
|
||||
* @param tagEditorRoot
|
||||
* The root DOM element of the widget.
|
||||
*/
|
||||
constructor(tagEditorRoot) {
|
||||
this.widget = tagEditorRoot;
|
||||
this.taggingBase = new TaggingBase(this.widget);
|
||||
this.addTagControl = new AddNewTagControl(
|
||||
this.widget, this.taggingBase, (t) => this.insertNewTag(t)
|
||||
);
|
||||
|
||||
this.currentTags = this.widget.querySelector('.current_tags');
|
||||
this.newTags = this.widget.querySelector('.new-tags');
|
||||
this.inputElement = this.widget.querySelector('input[type="hidden"]');
|
||||
|
||||
for (const tag of this.newTags.querySelectorAll(".tag")) {
|
||||
this.configureAddTag(tag);
|
||||
}
|
||||
for (const tag of this.currentTags.querySelectorAll(".tag")) {
|
||||
this.configureRemoveTag(tag);
|
||||
}
|
||||
|
||||
this.updateInputList();
|
||||
this.taggingBase.addTagListUpdatedListener(
|
||||
() => this.updateInputList()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new tag into the "current tag" list.
|
||||
*
|
||||
* Makes sure that no duplicates are present in the widget before adding
|
||||
* the new tag. If a duplicate is found, the old tag is removed before
|
||||
* the new one is added.
|
||||
*/
|
||||
insertNewTag(tag) {
|
||||
const name = tag.getAttribute("data-value");
|
||||
|
||||
const oldTag = this.widget.querySelector(`span[data-value="${name}"]`);
|
||||
if (oldTag) {
|
||||
oldTag.parentNode.removeChild(oldTag);
|
||||
}
|
||||
|
||||
this.taggingBase.insertTag(this.currentTags, tag);
|
||||
this.configureRemoveTag(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registeres a click-callback for a given node.
|
||||
*
|
||||
* The callback chain-calls another callback "onClicked" after
|
||||
* moving the clicked tag from the old tag-list to a new tag list.
|
||||
*/
|
||||
registerNewCallback(tag, newParent, onClicked) {
|
||||
function callback(event) {
|
||||
tag.parentNode.removeChild(tag);
|
||||
this.taggingBase.insertTag(newParent, tag);
|
||||
|
||||
tag.removeEventListener('click', callback);
|
||||
onClicked(tag);
|
||||
}
|
||||
tag.addEventListener('click', callback.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the value of the hidden input element.
|
||||
*
|
||||
* Sets the value from the list of tags added to the currentTags
|
||||
* DOM element.
|
||||
*/
|
||||
updateInputList() {
|
||||
const names = [];
|
||||
for (const tag of this.currentTags.querySelectorAll(".tag")) {
|
||||
const name = tag.getAttribute("data-value");
|
||||
names.push(`"${name}"`);
|
||||
}
|
||||
this.inputElement.value = names.join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a tag-DOM element as a "add tag" button.
|
||||
*/
|
||||
configureAddTag(tag) {
|
||||
this.taggingBase.updateTag(tag, null, null, "+");
|
||||
this.registerNewCallback(tag, this.currentTags, () => this.configureRemoveTag(tag));
|
||||
this.updateInputList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a tag-DOM element as a "remove tag" button.
|
||||
*/
|
||||
configureRemoveTag(tag) {
|
||||
this.taggingBase.updateTag(tag, null, null, "-");
|
||||
this.registerNewCallback(tag, this.newTags, () => this.configureAddTag(tag));
|
||||
this.updateInputList();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
for (const el of document.querySelectorAll('.babybuddy-tags-editor')) {
|
||||
new TagsEditor(el);
|
||||
}
|
||||
});
|
||||
})();
|
Binary file not shown.
|
@ -0,0 +1,354 @@
|
|||
(function() {
|
||||
/**
|
||||
* Parse a string as hexadecimal number
|
||||
*/
|
||||
function hexParse(x) {
|
||||
return parseInt(x, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the contrasting color for any hex color
|
||||
*
|
||||
* Sourced from: https://vanillajstoolkit.com/helpers/getcontrast/
|
||||
* - Modified with slightly softer colors
|
||||
* (c) 2021 Chris Ferdinandi, MIT License, https://gomakethings.com
|
||||
* Derived from work by Brian Suda, https://24ways.org/2010/calculating-color-contrast/
|
||||
* @param {String} A hexcolor value
|
||||
* @return {String} The contrasting color (black or white)
|
||||
*/
|
||||
function computeComplementaryColor(hexcolor) {
|
||||
|
||||
// If a leading # is provided, remove it
|
||||
if (hexcolor.slice(0, 1) === '#') {
|
||||
hexcolor = hexcolor.slice(1);
|
||||
}
|
||||
|
||||
// If a three-character hexcode, make six-character
|
||||
if (hexcolor.length === 3) {
|
||||
hexcolor = hexcolor.split('').map(function (hex) {
|
||||
return hex + hex;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Convert to RGB value
|
||||
let r = parseInt(hexcolor.substr(0,2),16);
|
||||
let g = parseInt(hexcolor.substr(2,2),16);
|
||||
let b = parseInt(hexcolor.substr(4,2),16);
|
||||
|
||||
// Get YIQ ratio
|
||||
let yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
||||
|
||||
// Check contrast
|
||||
return (yiq >= 128) ? '#101010' : '#EFEFEF';
|
||||
}
|
||||
|
||||
// CSRF token should always be present because it is auto-included with
|
||||
// every tag-editor widget
|
||||
const CSRF_TOKEN = document.querySelector('input[name="csrfmiddlewaretoken"]').value;
|
||||
|
||||
function doReq(method, uri, data, success, fail) {
|
||||
// TODO: prefer jQuery based requests for now
|
||||
|
||||
const req = new XMLHttpRequest();
|
||||
req.addEventListener('load', () => {
|
||||
if ((req.status >= 200) && (req.status < 300)) {
|
||||
success(req.responseText, req);
|
||||
} else {
|
||||
fail(req.responseText, req);
|
||||
}
|
||||
});
|
||||
for (const name of ["error", "timeout", "abort"]) {
|
||||
req.addEventListener(name, () => {
|
||||
fail(req.responseText, req);
|
||||
});
|
||||
}
|
||||
req.timeout = 20000;
|
||||
|
||||
req.open(method, uri);
|
||||
req.setRequestHeader("Content-Type", "application/json");
|
||||
req.setRequestHeader("Accept", "application/json");
|
||||
req.setRequestHeader("X-CSRFTOKEN", CSRF_TOKEN);
|
||||
req.send(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class allowing generic operations on the tag lists, like:
|
||||
*
|
||||
* - Adding tags to a tag list
|
||||
* - Updating or creating new tags with a set name and color
|
||||
* - Controlling the error modal
|
||||
*/
|
||||
class TaggingBase {
|
||||
constructor(widget) {
|
||||
this.prototype = widget.querySelector('.prototype-tag');
|
||||
this.listeners = [];
|
||||
|
||||
this.modalElement = widget.querySelector('.tag-editor-error-modal');
|
||||
this.modalBodyNode = this.modalElement.querySelector('.modal-body');
|
||||
|
||||
// Clean whitespace text nodes between spans
|
||||
for (const n of this.modalBodyNode.childNodes) {
|
||||
if (n.nodeType === Node.TEXT_NODE) {
|
||||
this.modalBodyNode.removeChild(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showModal(msg) {
|
||||
const selectedMessage = this.modalBodyNode.querySelector(`span[data-message='${msg}']`);
|
||||
if (!selectedMessage) {
|
||||
selectedMessage = this.modalBodyNode.childNodes[0];
|
||||
}
|
||||
|
||||
for (const n of this.modalBodyNode.childNodes) {
|
||||
n.classList.add('d-none');
|
||||
}
|
||||
selectedMessage.classList.remove('d-none');
|
||||
|
||||
jQuery(this.modalElement).modal('show');
|
||||
}
|
||||
|
||||
addTagListUpdatedListener(c) {
|
||||
this.listeners.push(c);
|
||||
}
|
||||
|
||||
callTagListUpdatedListeners() {
|
||||
for (const l of this.listeners) {
|
||||
l();
|
||||
}
|
||||
}
|
||||
|
||||
updateTag(tag, name, color, actionSymbol) {
|
||||
const actionTextNode = tag.querySelector('.add-remove-icon').childNodes[0];
|
||||
|
||||
name = name || tag.getAttribute("data-value");
|
||||
color = color || tag.getAttribute("data-color");
|
||||
actionSymbol = actionSymbol || actionTextNode.textContent;
|
||||
|
||||
tag.childNodes[0].textContent = name;
|
||||
tag.setAttribute("data-value", name);
|
||||
tag.setAttribute("data-color", color);
|
||||
|
||||
const textColor = computeComplementaryColor(color);
|
||||
tag.setAttribute('style', `background-color: ${color}; color: ${textColor};`);
|
||||
actionTextNode.textContent = actionSymbol;
|
||||
}
|
||||
|
||||
createNewTag(name, color, actionSymbol) {
|
||||
const tag = this.prototype.cloneNode(true);
|
||||
tag.classList.remove("prototype-tag");
|
||||
tag.classList.add("tag");
|
||||
this.updateTag(tag, name, color, actionSymbol);
|
||||
return tag;
|
||||
}
|
||||
|
||||
insertTag(list, tag) {
|
||||
list.appendChild(tag);
|
||||
this.callTagListUpdatedListeners();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handler for the edit field allowing to dynamically create new tags.
|
||||
*
|
||||
* Handles user inputs for the editor. Calls the 'onInsertNewTag' callback
|
||||
* when the creation of a new tag has been requested. All backend handling
|
||||
* like guareteening that the requested tag exists is handled by this class,
|
||||
* the only task left is to add the new tag to the tags-list when
|
||||
* 'onInsertNewTag' is called.
|
||||
*/
|
||||
class AddNewTagControl {
|
||||
/**
|
||||
* @param widget
|
||||
* The root DOM element of the widget
|
||||
* @param taggingBase
|
||||
* Reference to a common TaggingBase class to be used by this widget
|
||||
* @param onInsertNewTag
|
||||
* Callback that is called when a new tag should be added to the
|
||||
* tags widget.
|
||||
*/
|
||||
constructor(widget, taggingBase, onInsertNewTag) {
|
||||
this.widget = widget;
|
||||
this.taggingBase = taggingBase;
|
||||
|
||||
this.apiTagsUrl = widget.getAttribute('data-tags-url');
|
||||
this.createTagInputs = widget.querySelector('.create-tag-inputs');
|
||||
this.addTagInput = this.createTagInputs.querySelector('input[type="text"]');
|
||||
this.addTagButton = this.createTagInputs.querySelector('.btn-add-new-tag');
|
||||
|
||||
this.addTagInput.value = "";
|
||||
|
||||
this.onInsertNewTag = onInsertNewTag;
|
||||
|
||||
this.addTagButton.addEventListener('click', () => this.onCreateTagClicked());
|
||||
this.addTagInput.addEventListener('keydown', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (key === "enter") {
|
||||
e.preventDefault();
|
||||
this.onCreateTagClicked();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback called when the the "Add" button of the add-tag input is
|
||||
* clicked or enter is pressed in the editor.
|
||||
*/
|
||||
onCreateTagClicked() {
|
||||
// TODO: Make promise based
|
||||
|
||||
const tagName = this.addTagInput.value.trim();
|
||||
const uriTagName = encodeURIComponent(tagName);
|
||||
|
||||
const fail = (msg) => {
|
||||
this.addTagInput.select();
|
||||
this.taggingBase.showModal(msg || "generic");
|
||||
};
|
||||
|
||||
if (!tagName) {
|
||||
fail('invalid-tag-name');
|
||||
return;
|
||||
}
|
||||
|
||||
const addTag = (name, color) => {
|
||||
const tag = this.taggingBase.createNewTag(name, color, "-");
|
||||
this.addTagInput.value = "";
|
||||
this.onInsertNewTag(tag);
|
||||
};
|
||||
|
||||
const data = JSON.stringify({
|
||||
'name': this.addTagInput.value
|
||||
});
|
||||
|
||||
doReq("GET", `${this.apiTagsUrl}?name=${uriTagName}`, null,
|
||||
(text) => {
|
||||
const json = JSON.parse(text);
|
||||
if (json.count) {
|
||||
const tagJson = json.results[0];
|
||||
addTag(tagJson.name, tagJson.color);
|
||||
} else {
|
||||
doReq("POST", this.apiTagsUrl, data,
|
||||
(text) => {
|
||||
const tagJson = JSON.parse(text);
|
||||
addTag(tagJson.name, tagJson.color);
|
||||
}, () => fail("tag-creation-failed")
|
||||
);
|
||||
}
|
||||
}, () => fail("tag-checking-failed")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* JavaScript implementation for the tags editor.
|
||||
*
|
||||
* This class uses TaggingBase and AddNewTagControl to provide the custom
|
||||
* tag editor controls. This mainly consists of updating the hidden
|
||||
* input values with the current list of tags and adding/removing
|
||||
* tags from the current-tags- or recently-used-lists.
|
||||
*/
|
||||
class TagsEditor {
|
||||
/**
|
||||
* @param tagEditorRoot
|
||||
* The root DOM element of the widget.
|
||||
*/
|
||||
constructor(tagEditorRoot) {
|
||||
this.widget = tagEditorRoot;
|
||||
this.taggingBase = new TaggingBase(this.widget);
|
||||
this.addTagControl = new AddNewTagControl(
|
||||
this.widget, this.taggingBase, (t) => this.insertNewTag(t)
|
||||
);
|
||||
|
||||
this.currentTags = this.widget.querySelector('.current_tags');
|
||||
this.newTags = this.widget.querySelector('.new-tags');
|
||||
this.inputElement = this.widget.querySelector('input[type="hidden"]');
|
||||
|
||||
for (const tag of this.newTags.querySelectorAll(".tag")) {
|
||||
this.configureAddTag(tag);
|
||||
}
|
||||
for (const tag of this.currentTags.querySelectorAll(".tag")) {
|
||||
this.configureRemoveTag(tag);
|
||||
}
|
||||
|
||||
this.updateInputList();
|
||||
this.taggingBase.addTagListUpdatedListener(
|
||||
() => this.updateInputList()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new tag into the "current tag" list.
|
||||
*
|
||||
* Makes sure that no duplicates are present in the widget before adding
|
||||
* the new tag. If a duplicate is found, the old tag is removed before
|
||||
* the new one is added.
|
||||
*/
|
||||
insertNewTag(tag) {
|
||||
const name = tag.getAttribute("data-value");
|
||||
|
||||
const oldTag = this.widget.querySelector(`span[data-value="${name}"]`);
|
||||
if (oldTag) {
|
||||
oldTag.parentNode.removeChild(oldTag);
|
||||
}
|
||||
|
||||
this.taggingBase.insertTag(this.currentTags, tag);
|
||||
this.configureRemoveTag(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registeres a click-callback for a given node.
|
||||
*
|
||||
* The callback chain-calls another callback "onClicked" after
|
||||
* moving the clicked tag from the old tag-list to a new tag list.
|
||||
*/
|
||||
registerNewCallback(tag, newParent, onClicked) {
|
||||
function callback(event) {
|
||||
tag.parentNode.removeChild(tag);
|
||||
this.taggingBase.insertTag(newParent, tag);
|
||||
|
||||
tag.removeEventListener('click', callback);
|
||||
onClicked(tag);
|
||||
}
|
||||
tag.addEventListener('click', callback.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the value of the hidden input element.
|
||||
*
|
||||
* Sets the value from the list of tags added to the currentTags
|
||||
* DOM element.
|
||||
*/
|
||||
updateInputList() {
|
||||
const names = [];
|
||||
for (const tag of this.currentTags.querySelectorAll(".tag")) {
|
||||
const name = tag.getAttribute("data-value");
|
||||
names.push(`"${name}"`);
|
||||
}
|
||||
this.inputElement.value = names.join(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a tag-DOM element as a "add tag" button.
|
||||
*/
|
||||
configureAddTag(tag) {
|
||||
this.taggingBase.updateTag(tag, null, null, "+");
|
||||
this.registerNewCallback(tag, this.currentTags, () => this.configureRemoveTag(tag));
|
||||
this.updateInputList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a tag-DOM element as a "remove tag" button.
|
||||
*/
|
||||
configureRemoveTag(tag) {
|
||||
this.taggingBase.updateTag(tag, null, null, "-");
|
||||
this.registerNewCallback(tag, this.newTags, () => this.configureAddTag(tag));
|
||||
this.updateInputList();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
for (const el of document.querySelectorAll('.babybuddy-tags-editor')) {
|
||||
new TagsEditor(el);
|
||||
}
|
||||
});
|
||||
})();
|
Binary file not shown.
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue