django-spire 0.19.4__py3-none-any.whl → 0.20.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. django_spire/consts.py +1 -1
  2. django_spire/core/migrations/0001_initial.py +26 -0
  3. django_spire/core/migrations/__init__.py +0 -0
  4. django_spire/core/static/django_spire/css/app-background.css +6 -0
  5. django_spire/core/static/django_spire/css/app-import.css +1 -0
  6. django_spire/core/static/django_spire/css/app-override.css +0 -0
  7. django_spire/core/static/django_spire/css/themes/ayu/app-dark.css +2 -0
  8. django_spire/core/static/django_spire/css/themes/ayu/app-light.css +2 -0
  9. django_spire/core/static/django_spire/css/themes/catppuccin/app-dark.css +2 -0
  10. django_spire/core/static/django_spire/css/themes/catppuccin/app-light.css +2 -0
  11. django_spire/core/static/django_spire/css/themes/default/app-dark.css +2 -0
  12. django_spire/core/static/django_spire/css/themes/default/app-light.css +2 -0
  13. django_spire/core/static/django_spire/css/themes/gruvbox/app-dark.css +2 -0
  14. django_spire/core/static/django_spire/css/themes/gruvbox/app-light.css +2 -0
  15. django_spire/core/static/django_spire/css/themes/material/app-dark.css +2 -0
  16. django_spire/core/static/django_spire/css/themes/material/app-light.css +2 -0
  17. django_spire/core/static/django_spire/css/themes/nord/app-dark.css +2 -0
  18. django_spire/core/static/django_spire/css/themes/nord/app-light.css +2 -0
  19. django_spire/core/static/django_spire/css/themes/one-dark/app-dark.css +2 -0
  20. django_spire/core/static/django_spire/css/themes/one-dark/app-light.css +2 -0
  21. django_spire/core/static/django_spire/css/themes/palenight/app-dark.css +2 -0
  22. django_spire/core/static/django_spire/css/themes/palenight/app-light.css +2 -0
  23. django_spire/core/static/django_spire/css/themes/rose-pine/app-dark.css +2 -0
  24. django_spire/core/static/django_spire/css/themes/rose-pine/app-light.css +2 -0
  25. django_spire/core/static/django_spire/css/themes/tokyo-night/app-dark.css +2 -0
  26. django_spire/core/static/django_spire/css/themes/tokyo-night/app-light.css +2 -0
  27. django_spire/core/tags/__init__.py +0 -0
  28. django_spire/core/tags/intelligence/__init__.py +0 -0
  29. django_spire/core/tags/intelligence/tag_set_bot.py +41 -0
  30. django_spire/core/tags/mixins.py +61 -0
  31. django_spire/core/tags/models.py +38 -0
  32. django_spire/core/tags/querysets.py +9 -0
  33. django_spire/core/tags/tests/__init__.py +0 -0
  34. django_spire/core/tags/tests/test_intelligence.py +28 -0
  35. django_spire/core/tags/tests/test_tags.py +102 -0
  36. django_spire/core/tags/tools.py +20 -0
  37. django_spire/knowledge/collection/admin.py +20 -1
  38. django_spire/knowledge/collection/models.py +6 -1
  39. django_spire/knowledge/collection/services/service.py +2 -0
  40. django_spire/knowledge/collection/services/tag_service.py +52 -0
  41. django_spire/knowledge/collection/views/form_views.py +2 -0
  42. django_spire/knowledge/entry/admin.py +18 -2
  43. django_spire/knowledge/entry/models.py +6 -1
  44. django_spire/knowledge/entry/services/service.py +2 -0
  45. django_spire/knowledge/entry/services/tag_service.py +34 -0
  46. django_spire/knowledge/entry/version/urls/json_urls.py +5 -1
  47. django_spire/knowledge/entry/version/views/json_views.py +9 -0
  48. django_spire/knowledge/intelligence/decoders/collection_decoder.py +4 -4
  49. django_spire/knowledge/intelligence/decoders/entry_decoder.py +2 -2
  50. django_spire/knowledge/intelligence/workflows/knowledge_workflow.py +9 -3
  51. django_spire/knowledge/migrations/0008_collection_tags_entry_tags.py +24 -0
  52. django_spire/knowledge/templates/django_spire/knowledge/collection/page/display_page.html +16 -0
  53. django_spire/knowledge/templates/django_spire/knowledge/entry/version/container/{detail_container.html → editor_container.html} +6 -0
  54. django_spire/knowledge/templates/django_spire/knowledge/entry/version/page/editor_page.html +27 -10
  55. django_spire/theme/enums.py +0 -3
  56. django_spire/theme/models.py +0 -3
  57. django_spire/theme/tests/test_context_processor.py +6 -6
  58. django_spire/theme/tests/test_enums.py +0 -3
  59. django_spire/theme/tests/test_integration.py +2 -2
  60. django_spire/theme/tests/test_model.py +23 -24
  61. django_spire/theme/tests/test_views/test_json_views.py +4 -4
  62. {django_spire-0.19.4.dist-info → django_spire-0.20.0.dist-info}/METADATA +2 -2
  63. {django_spire-0.19.4.dist-info → django_spire-0.20.0.dist-info}/RECORD +66 -56
  64. django_spire/core/static/django_spire/css/themes/dracula/app-dark.css +0 -71
  65. django_spire/core/static/django_spire/css/themes/dracula/app-light.css +0 -66
  66. django_spire/core/static/django_spire/css/themes/oceanic-next/app-dark.css +0 -71
  67. django_spire/core/static/django_spire/css/themes/oceanic-next/app-light.css +0 -66
  68. django_spire/core/static/django_spire/css/themes/synthwave/app-dark.css +0 -71
  69. django_spire/core/static/django_spire/css/themes/synthwave/app-light.css +0 -66
  70. {django_spire-0.19.4.dist-info → django_spire-0.20.0.dist-info}/WHEEL +0 -0
  71. {django_spire-0.19.4.dist-info → django_spire-0.20.0.dist-info}/licenses/LICENSE.md +0 -0
  72. {django_spire-0.19.4.dist-info → django_spire-0.20.0.dist-info}/top_level.txt +0 -0
django_spire/consts.py CHANGED
@@ -1,4 +1,4 @@
1
- __VERSION__ = '0.19.4'
1
+ __VERSION__ = '0.20.0'
2
2
 
3
3
  MAINTENANCE_MODE_SETTINGS_NAME = 'MAINTENANCE_MODE'
4
4
 
@@ -0,0 +1,26 @@
1
+ # Generated by Django 5.2.7 on 2025-11-09 05:02
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ initial = True
9
+
10
+ dependencies = [
11
+ ]
12
+
13
+ operations = [
14
+ migrations.CreateModel(
15
+ name='Tag',
16
+ fields=[
17
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18
+ ('name', models.SlugField(max_length=128, unique=True)),
19
+ ],
20
+ options={
21
+ 'verbose_name': 'Tag',
22
+ 'verbose_name_plural': 'Tags',
23
+ 'db_table': 'django_spire_core_tag',
24
+ },
25
+ ),
26
+ ]
File without changes
@@ -33,6 +33,8 @@
33
33
  --app-bg-alt-layer-three: var(--app-alt-layer-three);
34
34
  --app-bg-alt-layer-four: var(--app-alt-layer-four);
35
35
 
36
+ --app-bg-card-header: var(--app-card-header);
37
+
36
38
  --app-bg-side-navigation: var(--app-side-navigation-bg-color);
37
39
 
38
40
  --app-bg-top-navigation: var(--app-top-navigation-bg-color);
@@ -151,6 +153,10 @@
151
153
  background-color: var(--app-bg-alt-layer-four) !important;
152
154
  }
153
155
 
156
+ .bg-app-card-header {
157
+ background-color: var(--app-bg-card-header) !important;
158
+ }
159
+
154
160
  .bg-app-side-navigation, .bg-app-side-navigation-hover:hover {
155
161
  background-color: var(--app-bg-side-navigation) !important;
156
162
  }
@@ -10,3 +10,4 @@
10
10
  @import url('app-side-panel.css');
11
11
  @import url('app-text.css');
12
12
  @import url('app-template.css');
13
+ @import url('app-override.css');
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #232834;
34
34
  --app-alt-layer-four: #343a46;
35
35
 
36
+ --app-card-header: #1f2430;
37
+
36
38
  --app-default-attribute-title-color: #bfbdb6;
37
39
  --app-default-button-text-color: #0f1419;
38
40
  --app-default-text-color: #bfbdb6;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #f7f8f9;
34
34
  --app-alt-layer-four: #eff1f3;
35
35
 
36
+ --app-card-header: #f0f0f0;
37
+
36
38
  --app-default-attribute-title-color: #828c99;
37
39
  --app-default-button-text-color: #fafafa;
38
40
  --app-default-text-color: #5c6773;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #393952;
34
34
  --app-alt-layer-four: #4a4b61;
35
35
 
36
+ --app-card-header: #313244;
37
+
36
38
  --app-default-attribute-title-color: #cdd6f4;
37
39
  --app-default-button-text-color: #1e1e2e;
38
40
  --app-default-text-color: #cdd6f4;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #a6adc8;
34
34
  --app-alt-layer-four: #9399b2;
35
35
 
36
+ --app-card-header: #dce0e8;
37
+
36
38
  --app-default-attribute-title-color: #6c6f85;
37
39
  --app-default-button-text-color: #eff1f5;
38
40
  --app-default-text-color: #4c4f69;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #292c2d;
34
34
  --app-alt-layer-four: #343738;
35
35
 
36
+ --app-card-header: #232526;
37
+
36
38
  --app-default-attribute-title-color: #ffffff;
37
39
  --app-default-button-text-color: #181818;
38
40
  --app-default-text-color: #ffffff;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #fcfcfc;
34
34
  --app-alt-layer-four: #f8f9fa;
35
35
 
36
+ --app-card-header: #f5f5f6;
37
+
36
38
  --app-default-attribute-title-color: #797c7d;
37
39
  --app-default-button-text-color: #ffffff;
38
40
  --app-default-text-color: #181818;
@@ -28,6 +28,8 @@
28
28
  --app-layer-three: #504945;
29
29
  --app-layer-four: #665c54;
30
30
 
31
+ --app-card-header: #3c3836;
32
+
31
33
  --app-alt-layer-one: #1d2021;
32
34
  --app-alt-layer-two: #32302f;
33
35
  --app-alt-layer-three: #45403d;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #e5d5b1;
34
34
  --app-alt-layer-four: #d0bfa5;
35
35
 
36
+ --app-card-header: #ebdbb2;
37
+
36
38
  --app-default-attribute-title-color: #665c54;
37
39
  --app-default-button-text-color: #fbf1c7;
38
40
  --app-default-text-color: #3c3836;
@@ -28,6 +28,8 @@
28
28
  --app-layer-three: #2d2d2d;
29
29
  --app-layer-four: #404040;
30
30
 
31
+ --app-card-header: #1e1e1e;
32
+
31
33
  --app-alt-layer-one: #0a0a0a;
32
34
  --app-alt-layer-two: #171717;
33
35
  --app-alt-layer-three: #262626;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #e8e8e8;
34
34
  --app-alt-layer-four: #d8d8d8;
35
35
 
36
+ --app-card-header: #f5f5f5;
37
+
36
38
  --app-default-attribute-title-color: #607d8b;
37
39
  --app-default-button-text-color: #ffffff;
38
40
  --app-default-text-color: #212121;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #3e4758;
34
34
  --app-alt-layer-four: #475164;
35
35
 
36
+ --app-card-header: #3b4252;
37
+
36
38
  --app-default-attribute-title-color: #eceff4;
37
39
  --app-default-button-text-color: #2e3440;
38
40
  --app-default-text-color: #eceff4;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #dfe5ef;
34
34
  --app-alt-layer-four: #c8d0df;
35
35
 
36
+ --app-card-header: #d8dee9;
37
+
36
38
  --app-default-attribute-title-color: #4c566a;
37
39
  --app-default-button-text-color: #eceff4;
38
40
  --app-default-text-color: #2e3440;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #333841;
34
34
  --app-alt-layer-four: #424855;
35
35
 
36
+ --app-card-header: #2c313c;
37
+
36
38
  --app-default-attribute-title-color: #abb2bf;
37
39
  --app-default-button-text-color: #21252b;
38
40
  --app-default-text-color: #abb2bf;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #eeeeef;
34
34
  --app-alt-layer-four: #e0e0e2;
35
35
 
36
+ --app-card-header: #e5e5e6;
37
+
36
38
  --app-default-attribute-title-color: #6c6f93;
37
39
  --app-default-button-text-color: #fafafa;
38
40
  --app-default-text-color: #383a42;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #3e4267;
34
34
  --app-alt-layer-four: #575c7c;
35
35
 
36
+ --app-card-header: #444267;
37
+
36
38
  --app-default-attribute-title-color: #a6accd;
37
39
  --app-default-button-text-color: #292d3e;
38
40
  --app-default-text-color: #a6accd;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #f8f9fc;
34
34
  --app-alt-layer-four: #f0f1f5;
35
35
 
36
+ --app-card-header: #e9eaef;
37
+
36
38
  --app-default-attribute-title-color: #8087a2;
37
39
  --app-default-button-text-color: #fafafa;
38
40
  --app-default-text-color: #4e5579;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #393748;
34
34
  --app-alt-layer-four: #524f67;
35
35
 
36
+ --app-card-header: #26233a;
37
+
36
38
  --app-default-attribute-title-color: #e0def4;
37
39
  --app-default-button-text-color: #191724;
38
40
  --app-default-text-color: #e0def4;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #e9dfd8;
34
34
  --app-alt-layer-four: #d6d2d5;
35
35
 
36
+ --app-card-header: #e6ddd6;
37
+
36
38
  --app-default-attribute-title-color: #797593;
37
39
  --app-default-button-text-color: #faf4ed;
38
40
  --app-default-text-color: #575279;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #292e42;
34
34
  --app-alt-layer-four: #3b4261;
35
35
 
36
+ --app-card-header: #24283b;
37
+
36
38
  --app-default-attribute-title-color: #c0caf5;
37
39
  --app-default-button-text-color: #1a1b26;
38
40
  --app-default-text-color: #c0caf5;
@@ -33,6 +33,8 @@
33
33
  --app-alt-layer-three: #f7f7f8;
34
34
  --app-alt-layer-four: #d6d6d8;
35
35
 
36
+ --app-card-header: #dfe0e5;
37
+
36
38
  --app-default-attribute-title-color: #6f7bb6;
37
39
  --app-default-button-text-color: #d5d6db;
38
40
  --app-default-text-color: #343b58;
File without changes
File without changes
@@ -0,0 +1,41 @@
1
+ from dandy import Bot, Prompt, BaseListIntel
2
+ from django.utils.text import slugify
3
+
4
+
5
+ class TagsIntel(BaseListIntel[list[str]]):
6
+ tags: list[str]
7
+
8
+
9
+ class TagSetBot(Bot):
10
+ llm_role = 'Tag Identifier'
11
+ llm_task = 'Read through the provided content and return a list of tags.'
12
+ llm_guidelines = (
13
+ Prompt()
14
+ .list([
15
+ 'Make sure to have enough tags to properly cover all the provided content.',
16
+ 'Focus on tagging the words in the content',
17
+ 'Only add additional words that are very relevant to the content.'
18
+ 'Use spaces to separate words in tags',
19
+ 'Include known acronyms along with the full tags.',
20
+ ])
21
+ )
22
+
23
+ def process(self, content: Prompt | str) -> set[str]:
24
+ tags_intel = self.llm.prompt_to_intel(
25
+ prompt=(
26
+ Prompt()
27
+ .heading('Content to be Tagged:')
28
+ .line_break()
29
+ .text(content)
30
+ ),
31
+ intel_class=TagsIntel
32
+ )
33
+
34
+ for tag in tags_intel:
35
+ word_segments = tag.split(" ")
36
+
37
+ if len(word_segments) > 1:
38
+ for word_segment in word_segments:
39
+ tags_intel.append(word_segment)
40
+
41
+ return {slugify(tag) for tag in tags_intel}
@@ -0,0 +1,61 @@
1
+ from django.db import models
2
+ from django.db.models import QuerySet
3
+
4
+ from django_spire.core.tags.models import Tag
5
+ from django_spire.core.tags import tools
6
+
7
+
8
+ class TagsModelMixin(models.Model):
9
+ tags = models.ManyToManyField(Tag, related_name='+', null=True, blank=True, editable=False)
10
+
11
+ class Meta:
12
+ abstract = True
13
+
14
+ @property
15
+ def tag_set(self) -> set[str]:
16
+ return set(self.tags.values_list('name', flat=True))
17
+
18
+ @property
19
+ def tag_set_simplified(self) -> set[str]:
20
+ simplified_tag_words = []
21
+
22
+ for tag in self.tag_set:
23
+ tag_words = tag.split('-')
24
+ simplified_tag_words.extend(tag_words)
25
+
26
+ return set(simplified_tag_words)
27
+
28
+ def add_tags_from_tag_set(self, tag_set: set[str]):
29
+ self._update_global_tags_from_set(tag_set)
30
+
31
+ self.tags.add(*Tag.objects.in_tag_set(tag_set))
32
+
33
+ def clear_tags(self):
34
+ self.tags.clear()
35
+
36
+ def get_matching_tags_from_tag_set(self, tag_set: set[str]) -> QuerySet:
37
+ return self.tags.in_tag_set(tag_set)
38
+
39
+ def get_matching_percentage_of_tag_set(self, tag_set: set[str]) -> float:
40
+ return tools.get_matching_a_percentage_from_tag_sets(tag_set, self.tag_set)
41
+
42
+ def get_matching_percentage_of_model_tags_from_tag_set(self, tag_set: set[str]) -> float:
43
+ return tools.get_matching_b_percentage_from_tag_sets(tag_set, self.tag_set)
44
+
45
+ def has_tags_in_tag_set(self, tag_set: set[str]) -> bool:
46
+ return self.tag_set.issuperset(tag_set)
47
+
48
+ def remove_tags_by_tag_set(self, tag_set: set[str]):
49
+ tag_objects = Tag.objects.in_tag_set(tag_set)
50
+
51
+ self.tags.remove(*tag_objects)
52
+
53
+ def set_tags_from_tag_set(self, tag_set: set[str]):
54
+ self._update_global_tags_from_set(tag_set)
55
+
56
+ tag_objects = Tag.objects.in_tag_set(tag_set)
57
+ self.tags.set(tag_objects)
58
+
59
+ @staticmethod
60
+ def _update_global_tags_from_set(tag_set: set[str]):
61
+ Tag.add_tags([Tag(name=tag) for tag in tag_set])
@@ -0,0 +1,38 @@
1
+ from typing import Sequence, Self
2
+
3
+ from django.db import models
4
+ from django.utils.text import slugify
5
+
6
+ from django_spire.core.tags.querysets import TagQuerySet
7
+
8
+
9
+ class Tag(models.Model):
10
+ name = models.SlugField(
11
+ max_length=128,
12
+ unique=True
13
+ )
14
+
15
+ objects = TagQuerySet.as_manager()
16
+
17
+ def __init__(self, *args, **kwargs):
18
+ name = kwargs.get('name')
19
+ if name:
20
+ kwargs['name'] = slugify(name)
21
+
22
+ super().__init__(*args, **kwargs)
23
+
24
+ def __str__(self):
25
+ return self.name
26
+
27
+ @classmethod
28
+ def add_tags(cls, tags: Sequence[Self]):
29
+ cls.objects.bulk_create(
30
+ tags,
31
+ ignore_conflicts=True
32
+ )
33
+
34
+ class Meta:
35
+ db_table = 'django_spire_core_tag'
36
+ verbose_name = 'Tag'
37
+ verbose_name_plural = 'Tags'
38
+
@@ -0,0 +1,9 @@
1
+ from django.db.models import QuerySet
2
+ from django.utils.text import slugify
3
+
4
+
5
+ class TagQuerySet(QuerySet):
6
+ def in_tag_set(self, tag_set: set[str]):
7
+ slugified_tag_set = set(map(slugify, tag_set))
8
+
9
+ return self.filter(name__in=slugified_tag_set)
File without changes
@@ -0,0 +1,28 @@
1
+ from unittest import TestCase
2
+
3
+ from django_spire.core.tags.intelligence.tag_set_bot import TagSetBot
4
+
5
+ TEST_INPUT = """
6
+ I love reading books about science fiction, fantasy, and adventure, especially ones
7
+ that feature artificial intelligence, machine learning, and natural language processing.
8
+ Some of my favorite authors include Isaac Asimov, J.R.R. Tolkien, and Neil Gaiman.
9
+ I'm also interested in learning more about data science, programming languages like Python
10
+ and Java, and emerging technologies like blockchain and the Internet of Things (IoT).
11
+ Can you recommend some books or resources that align with these interests?
12
+ """
13
+
14
+
15
+ class TestTagIntelligence(TestCase):
16
+ def setUp(self):
17
+ pass
18
+
19
+ def test_tag_set_bot(self):
20
+ tag_set = TagSetBot().process(TEST_INPUT)
21
+
22
+ self.assertIn('science', tag_set)
23
+ self.assertIn('artificial', tag_set)
24
+ self.assertIn('fantasy', tag_set)
25
+
26
+ self.assertNotIn('camping', tag_set)
27
+ self.assertNotIn('art', tag_set)
28
+ self.assertNotIn('hate', tag_set)
@@ -0,0 +1,102 @@
1
+ from django.test import TestCase
2
+
3
+ from django_spire.core.tags.models import Tag
4
+ from django_spire.knowledge.collection.models import Collection
5
+
6
+
7
+ class TestTags(TestCase):
8
+ def setUp(self):
9
+ self.collection = Collection.objects.create(
10
+ name='Test Collection', description='Test Collection Description'
11
+ )
12
+
13
+ self.tag_set_a = {'apple', 'banana', 'orange'}
14
+ self.tag_set_b = {'grape', 'kiwi', 'pear'}
15
+ self.tag_set_c = {'lemon', 'lime', 'mango'}
16
+
17
+ self.mixed_tag_set = {'apple', 'grape', 'lemon'}
18
+
19
+ self.dirty_tag_set = {'HaT~', 'four* tacos', '123 hoop!!'}
20
+
21
+ self.collection.add_tags_from_tag_set(self.tag_set_a)
22
+ self.collection.add_tags_from_tag_set(self.tag_set_b)
23
+ self.collection.add_tags_from_tag_set(self.tag_set_c)
24
+
25
+ self.collection.add_tags_from_tag_set(self.mixed_tag_set)
26
+
27
+ self.collection.add_tags_from_tag_set(self.dirty_tag_set)
28
+
29
+ def test_tag_removal(self):
30
+ self.assertIn('apple', self.collection.tag_set)
31
+
32
+ self.collection.remove_tags_by_tag_set(self.mixed_tag_set)
33
+
34
+ self.assertNotIn('lemon', self.collection.tag_set)
35
+
36
+ self.assertIn('kiwi', self.collection.tag_set)
37
+
38
+ def test_tag_add_and_set(self):
39
+ self.collection.set_tags_from_tag_set(self.mixed_tag_set)
40
+
41
+ self.assertIn('lemon', self.collection.tag_set)
42
+
43
+ self.assertNotIn('banana', self.collection.tag_set)
44
+
45
+ def test_tag_count(self):
46
+ self.assertEqual(Tag.objects.count(), 12)
47
+ self.assertEqual(self.collection.tags.count(), 12)
48
+
49
+ def test_tag_removal(self):
50
+ self.collection.remove_tags_by_tag_set(self.tag_set_a)
51
+
52
+ self.assertEqual(self.collection.tags.count(), 9)
53
+
54
+ def test_tag_bool_methods(self):
55
+ self.collection.remove_tags_by_tag_set(self.tag_set_b)
56
+
57
+ self.assertTrue(self.collection.has_tags_in_tag_set(self.tag_set_c))
58
+ self.assertFalse(self.collection.has_tags_in_tag_set(self.mixed_tag_set))
59
+
60
+ def test_tag_matching_methods(self):
61
+ self.assertEqual(len(self.collection.get_matching_tags_from_tag_set(self.tag_set_a)), 3)
62
+
63
+ self.collection.remove_tags_by_tag_set(self.tag_set_a)
64
+
65
+ self.assertEqual(len(self.collection.get_matching_tags_from_tag_set(self.tag_set_a)), 0)
66
+ self.assertEqual(len(self.collection.get_matching_tags_from_tag_set(self.tag_set_b)), 3)
67
+ self.assertEqual(len(self.collection.get_matching_tags_from_tag_set(self.mixed_tag_set)), 2)
68
+
69
+ self.collection.remove_tags_by_tag_set(self.tag_set_b)
70
+
71
+ self.assertEqual(len(self.collection.get_matching_tags_from_tag_set(self.tag_set_b)), 0)
72
+
73
+ self.assertEqual(len(self.collection.get_matching_tags_from_tag_set(self.mixed_tag_set)), 1)
74
+
75
+ def test_tag_metric_methods(self):
76
+ self.assertAlmostEqual(
77
+ self.collection.get_matching_percentage_of_tag_set(self.tag_set_a),
78
+ 1.00,
79
+ 2
80
+ )
81
+
82
+ self.assertAlmostEqual(
83
+ self.collection.get_matching_percentage_of_model_tags_from_tag_set(self.tag_set_b),
84
+ 0.25,
85
+ 2
86
+ )
87
+
88
+ self.collection.remove_tags_by_tag_set(self.mixed_tag_set)
89
+
90
+ self.assertAlmostEqual(
91
+ self.collection.get_matching_percentage_of_tag_set(self.tag_set_a),
92
+ 0.666,
93
+ 2
94
+ )
95
+
96
+
97
+ self.assertAlmostEqual(
98
+ self.collection.get_matching_percentage_of_model_tags_from_tag_set(self.tag_set_b),
99
+ 0.222,
100
+ 2
101
+ )
102
+
@@ -0,0 +1,20 @@
1
+ def get_matching_tag_set_from_tag_sets(tag_set_a: set[str], tag_set_b: set[str]) -> set[str]:
2
+ return tag_set_a.intersection(tag_set_b)
3
+
4
+
5
+ def get_matching_count_from_tag_sets(tag_set_a: set[str], tag_set_b: set[str]) -> int:
6
+ return len(get_matching_tag_set_from_tag_sets(tag_set_a, tag_set_b))
7
+
8
+
9
+ def get_matching_a_percentage_from_tag_sets(tag_set_a: set[str], tag_set_b: set[str]) -> float:
10
+ try:
11
+ return get_matching_count_from_tag_sets(tag_set_a, tag_set_b) / len(tag_set_a)
12
+ except ZeroDivisionError:
13
+ return 0.0
14
+
15
+
16
+ def get_matching_b_percentage_from_tag_sets(tag_set_a: set[str], tag_set_b: set[str]) -> float:
17
+ try:
18
+ return get_matching_count_from_tag_sets(tag_set_a, tag_set_b) / len(tag_set_b)
19
+ except ZeroDivisionError:
20
+ return 0.0
@@ -1,18 +1,35 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from django.contrib import admin
4
+ from django.db.models import QuerySet
5
+ from django.contrib import messages
4
6
 
5
7
  from .models import Collection, CollectionGroup
6
8
 
7
9
 
8
10
  @admin.register(Collection)
9
11
  class CollectionAdmin(admin.ModelAdmin):
10
- list_display = ['id', 'name', 'parent', 'is_deleted']
12
+ list_display = ['id', 'name', 'parent', 'is_deleted', 'tag_count']
11
13
  list_select_related = ['parent']
12
14
  list_filter = ['is_deleted', 'is_active']
13
15
  search_fields = ['id', 'name', 'description', 'parent__name']
14
16
  ordering = ['name']
15
17
  autocomplete_fields = ['parent']
18
+ actions = ['set_tags_for_collections']
19
+
20
+ @admin.action(description="Set Tags for Collections (Allow 5 Seconds Per)")
21
+ def set_tags_for_collections(self, request, queryset: QuerySet[Collection]):
22
+ processed = 0
23
+ for collection in queryset:
24
+ collection.services.tag.process_and_set_tags()
25
+ processed += 1
26
+
27
+ messages.success(request, f'Successfully processed {processed} collections.')
28
+
29
+ def tag_count(self, collection: Collection):
30
+ return collection.tags.count()
31
+
32
+ tag_count.short_description = 'Tags'
16
33
 
17
34
 
18
35
  @admin.register(CollectionGroup)
@@ -20,3 +37,5 @@ class CollectionGroupAdmin(admin.ModelAdmin):
20
37
  list_display = ['id', 'collection', 'auth_group']
21
38
  list_select_related = ['collection', 'auth_group']
22
39
  search_fields = ['id', 'collection__name', 'auth_group__name']
40
+
41
+
@@ -5,6 +5,7 @@ from django.db import models
5
5
  from django_spire.auth.group.models import AuthGroup
6
6
  from django_spire.contrib import Breadcrumbs
7
7
  from django_spire.contrib.ordering.mixins import OrderingModelMixin
8
+ from django_spire.core.tags.mixins import TagsModelMixin
8
9
  from django_spire.contrib.utils import truncate_string
9
10
  from django_spire.history.mixins import HistoryModelMixin
10
11
  from django_spire.knowledge.collection.querysets import CollectionQuerySet
@@ -12,7 +13,11 @@ from django_spire.knowledge.collection.services.service import CollectionGroupSe
12
13
  CollectionService
13
14
 
14
15
 
15
- class Collection(HistoryModelMixin, OrderingModelMixin):
16
+ class Collection(
17
+ HistoryModelMixin,
18
+ OrderingModelMixin,
19
+ TagsModelMixin,
20
+ ):
16
21
  parent = models.ForeignKey(
17
22
  'self',
18
23
  on_delete=models.CASCADE,
@@ -10,6 +10,7 @@ from django_spire.knowledge.collection.services.ordering_service import \
10
10
  CollectionOrderingService
11
11
  from django_spire.knowledge.collection.services.processor_service import \
12
12
  CollectionProcessorService
13
+ from django_spire.knowledge.collection.services.tag_service import CollectionTagService
13
14
  from django_spire.knowledge.collection.services.transformation_service import \
14
15
  CollectionTransformationService
15
16
 
@@ -22,6 +23,7 @@ class CollectionService(BaseDjangoModelService['Collection']):
22
23
 
23
24
  ordering = CollectionOrderingService()
24
25
  processor = CollectionProcessorService()
26
+ tag = CollectionTagService()
25
27
  transformation = CollectionTransformationService()
26
28
 
27
29
  def save_model_obj(self, **field_data) -> tuple[Collection, bool]: