diff --git a/apps/audit/signals.py b/apps/audit/signals.py index 601696c..e3434a3 100644 --- a/apps/audit/signals.py +++ b/apps/audit/signals.py @@ -8,7 +8,7 @@ from .service import log_event _TRACKED_FIELDS = ( - 'title', 'project_id', 'category_id', 'group_id', + 'title', 'project_id', 'category_id', 'entry_type_id', 'group_id', 'period_kind', 'period_start', 'period_end', 'description', 'is_private', 'is_critical', 'is_highlight', 'highlight_stars', 'is_division_head_only', 'author_id', 'is_archived', diff --git a/apps/core/templates/core/api.html b/apps/core/templates/core/api.html index 55c021c..1c7f1b8 100644 --- a/apps/core/templates/core/api.html +++ b/apps/core/templates/core/api.html @@ -244,6 +244,10 @@

Taxonomy

{% include "core/partials/_route_row.html" with method="POST" path="/taxonomy/categories/" badge="manager" desc="Create a new category" %} {% include "core/partials/_route_row.html" with method="GET" path="/taxonomy/categories/<id>/edit/" badge="manager" desc="Edit category form" %} {% include "core/partials/_route_row.html" with method="POST" path="/taxonomy/categories/<id>/edit/" badge="manager" desc="Save category changes" %} + {% include "core/partials/_route_row.html" with method="GET" path="/taxonomy/entry-types/" badge="manager" desc="Manage entry types — list and add" %} + {% include "core/partials/_route_row.html" with method="POST" path="/taxonomy/entry-types/" badge="manager" desc="Create a new entry type" %} + {% include "core/partials/_route_row.html" with method="GET" path="/taxonomy/entry-types/<id>/edit/" badge="manager" desc="Edit entry type form" %} + {% include "core/partials/_route_row.html" with method="POST" path="/taxonomy/entry-types/<id>/edit/" badge="manager" desc="Save entry type changes" %} {% include "core/partials/_route_row.html" with method="GET" path="/taxonomy/groups/" badge="manager" desc="Manage work groups — list and add" %} {% include "core/partials/_route_row.html" with method="POST" path="/taxonomy/groups/" badge="manager" desc="Create a new work group" %} {% include "core/partials/_route_row.html" with method="GET" path="/taxonomy/groups/<id>/edit/" badge="manager" desc="Edit work group form" %} diff --git a/apps/entries/admin.py b/apps/entries/admin.py index 7009eb9..480fec7 100644 --- a/apps/entries/admin.py +++ b/apps/entries/admin.py @@ -5,9 +5,9 @@ @admin.register(WorkItem) class WorkItemAdmin(admin.ModelAdmin): - list_display = ('title', 'author', 'project', 'category', + list_display = ('title', 'author', 'project', 'category', 'entry_type', 'period_start', 'period_end', 'is_private', 'created_at') - list_filter = ('project', 'category', 'is_private') + list_filter = ('project', 'category', 'entry_type', 'is_private') search_fields = ('title', 'description', 'author__email', 'author__display_name') raw_id_fields = ('author',) filter_horizontal = ('tags',) diff --git a/apps/entries/forms.py b/apps/entries/forms.py index 5d8d006..9e563b8 100644 --- a/apps/entries/forms.py +++ b/apps/entries/forms.py @@ -1,7 +1,7 @@ from django import forms from django.db.models import F -from apps.taxonomy.models import Category, LabPriority, Project, Tag, WorkGroup +from apps.taxonomy.models import Category, EntryType, LabPriority, Project, Tag, WorkGroup from .models import WorkItem @@ -12,7 +12,7 @@ class WorkItemForm(forms.ModelForm): class Meta: model = WorkItem fields = [ - 'title', 'project', 'category', 'group', 'lab_priority', + 'title', 'project', 'category', 'entry_type', 'group', 'lab_priority', 'period_kind', 'period_start', 'period_end', 'description', 'is_private', 'is_critical', 'is_highlight', 'highlight_stars', 'is_division_head_only', @@ -29,6 +29,8 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['project'].queryset = Project.objects.filter(is_active=True).order_by('sort_order', 'name') self.fields['category'].queryset = Category.objects.filter(is_active=True).order_by('sort_order', 'name') + self.fields['entry_type'].queryset = EntryType.objects.filter(is_active=True).order_by('sort_order', 'name') + self.fields['entry_type'].required = False self.fields['group'].queryset = WorkGroup.objects.filter(is_active=True).order_by('sort_order', 'name') self.fields['group'].required = False self.fields['lab_priority'].queryset = LabPriority.objects.filter(is_active=True).order_by('sort_order', 'name') diff --git a/apps/entries/migrations/0008_workitem_entry_type_and_more.py b/apps/entries/migrations/0008_workitem_entry_type_and_more.py new file mode 100644 index 0000000..e5c34f7 --- /dev/null +++ b/apps/entries/migrations/0008_workitem_entry_type_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.14 on 2026-06-25 17:07 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("entries", "0007_add_entry_template"), + ("taxonomy", "0004_entrytype"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="workitem", + name="entry_type", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="work_items", + to="taxonomy.entrytype", + ), + ), + migrations.AddIndex( + model_name="workitem", + index=models.Index( + fields=["entry_type", "-period_end"], + name="entries_wor_entry_t_d54d2e_idx", + ), + ), + ] diff --git a/apps/entries/models.py b/apps/entries/models.py index af5ccbb..fba1411 100644 --- a/apps/entries/models.py +++ b/apps/entries/models.py @@ -27,6 +27,12 @@ class PeriodKind(models.TextChoices): on_delete=models.PROTECT, related_name='work_items', ) + entry_type = models.ForeignKey( + 'taxonomy.EntryType', + null=True, blank=True, + on_delete=models.PROTECT, + related_name='work_items', + ) group = models.ForeignKey( 'taxonomy.WorkGroup', null=True, blank=True, @@ -66,6 +72,7 @@ class Meta: models.Index(fields=['author', '-period_end']), models.Index(fields=['project', '-period_end']), models.Index(fields=['category', '-period_end']), + models.Index(fields=['entry_type', '-period_end']), models.Index(fields=['period_start', 'period_end']), models.Index(fields=['is_private', '-period_end']), ] diff --git a/apps/entries/templates/entries/detail.html b/apps/entries/templates/entries/detail.html index 6fdda3e..bcf2b1a 100644 --- a/apps/entries/templates/entries/detail.html +++ b/apps/entries/templates/entries/detail.html @@ -55,6 +55,12 @@

{{ object.title }}

Category
{{ object.category.name }}
+ {% if object.entry_type %} +
+
Entry Type
+
{{ object.entry_type.name }}
+
+ {% endif %} {% if object.group %}
Group
diff --git a/apps/entries/templates/entries/form.html b/apps/entries/templates/entries/form.html index 44e7d13..0aaf5a2 100644 --- a/apps/entries/templates/entries/form.html +++ b/apps/entries/templates/entries/form.html @@ -42,7 +42,7 @@

{% endfor %}

- {# ── Project / Category / Group / Lab Priority ────────────────────────── #} + {# ── Project / Category / Entry Type / Group / Lab Priority ───────────── #}
+
+ + + {% for error in form.entry_type.errors %} +

{{ error }}

+ {% endfor %} +
+ {# Entry Type #} +
+ + +
+ {# Lab Priority #}
diff --git a/apps/taxonomy/admin.py b/apps/taxonomy/admin.py index acc99e8..5c33b95 100644 --- a/apps/taxonomy/admin.py +++ b/apps/taxonomy/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Category, LabPriority, Project, Tag, WorkGroup +from .models import Category, EntryType, LabPriority, Project, Tag, WorkGroup @admin.register(Project) @@ -19,6 +19,14 @@ class CategoryAdmin(admin.ModelAdmin): search_fields = ('name', 'short_code') +@admin.register(EntryType) +class EntryTypeAdmin(admin.ModelAdmin): + list_display = ('name', 'short_code', 'slug', 'is_active', 'sort_order') + list_editable = ('is_active', 'sort_order') + prepopulated_fields = {'slug': ('name',)} + search_fields = ('name', 'short_code') + + @admin.register(Tag) class TagAdmin(admin.ModelAdmin): list_display = ('name', 'use_count') diff --git a/apps/taxonomy/forms.py b/apps/taxonomy/forms.py index ef9e656..e173b75 100644 --- a/apps/taxonomy/forms.py +++ b/apps/taxonomy/forms.py @@ -1,6 +1,6 @@ from django import forms -from .models import Category, LabPriority, Project, WorkGroup +from .models import Category, EntryType, LabPriority, Project, WorkGroup class ProjectForm(forms.ModelForm): @@ -21,6 +21,15 @@ class Meta: } +class EntryTypeForm(forms.ModelForm): + class Meta: + model = EntryType + fields = ['name', 'short_code', 'is_active', 'sort_order'] + widgets = { + 'sort_order': forms.NumberInput(attrs={'min': 0}), + } + + class WorkGroupForm(forms.ModelForm): class Meta: model = WorkGroup diff --git a/apps/taxonomy/management/commands/seed_taxonomy.py b/apps/taxonomy/management/commands/seed_taxonomy.py index 132dc03..2e1d6c4 100644 --- a/apps/taxonomy/management/commands/seed_taxonomy.py +++ b/apps/taxonomy/management/commands/seed_taxonomy.py @@ -1,6 +1,6 @@ from django.core.management.base import BaseCommand -from apps.taxonomy.models import Category, LabPriority, Project +from apps.taxonomy.models import Category, EntryType, LabPriority, Project PROJECTS = [ {'name': 'DUNE', 'short_code': 'DUNE', 'sort_order': 10}, @@ -17,6 +17,12 @@ {'name': 'Training', 'short_code': 'TRN', 'sort_order': 40}, ] +ENTRY_TYPES = [ + {'name': 'Weekly Report', 'short_code': 'WEEKLY', 'sort_order': 10}, + {'name': 'Milestone', 'short_code': 'MILESTONE', 'sort_order': 20}, + {'name': 'Activity', 'short_code': 'ACTIVITY', 'sort_order': 30}, +] + LAB_PRIORITIES = [ {'name': 'Science Mission', 'short_code': 'SCI-MISS', 'sort_order': 10}, {'name': 'DUNE/LBNF', 'short_code': 'DUNE', 'sort_order': 20}, @@ -46,6 +52,14 @@ def handle(self, *args, **options): status = 'created' if created else 'exists ' self.stdout.write(f' [{status}] {name}') + self.stdout.write('Seeding entry types…') + for data in ENTRY_TYPES: + name = data['name'] + defaults = {k: v for k, v in data.items() if k != 'name'} + _, created = EntryType.objects.get_or_create(name=name, defaults=defaults) + status = 'created' if created else 'exists ' + self.stdout.write(f' [{status}] {name}') + self.stdout.write('Seeding lab priorities…') for data in LAB_PRIORITIES: name = data['name'] diff --git a/apps/taxonomy/migrations/0004_entrytype.py b/apps/taxonomy/migrations/0004_entrytype.py new file mode 100644 index 0000000..436d050 --- /dev/null +++ b/apps/taxonomy/migrations/0004_entrytype.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.14 on 2026-06-25 17:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("taxonomy", "0003_labpriority"), + ] + + operations = [ + migrations.CreateModel( + name="EntryType", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100, unique=True)), + ("slug", models.SlugField(blank=True, max_length=100, unique=True)), + ("short_code", models.CharField(blank=True, max_length=20)), + ("is_active", models.BooleanField(db_index=True, default=True)), + ( + "sort_order", + models.PositiveSmallIntegerField(db_index=True, default=0), + ), + ], + options={ + "verbose_name": "Entry Type", + "verbose_name_plural": "Entry Types", + "ordering": ["sort_order", "name"], + "abstract": False, + }, + ), + ] diff --git a/apps/taxonomy/models.py b/apps/taxonomy/models.py index 8cba0d2..600bdb7 100644 --- a/apps/taxonomy/models.py +++ b/apps/taxonomy/models.py @@ -32,6 +32,12 @@ class Meta(TaxonomyBase.Meta): verbose_name_plural = 'categories' +class EntryType(TaxonomyBase): + class Meta(TaxonomyBase.Meta): + verbose_name = 'Entry Type' + verbose_name_plural = 'Entry Types' + + class WorkGroup(TaxonomyBase): class Meta(TaxonomyBase.Meta): verbose_name = 'Group' diff --git a/apps/taxonomy/templates/taxonomy/categories.html b/apps/taxonomy/templates/taxonomy/categories.html index a0789c3..676500a 100644 --- a/apps/taxonomy/templates/taxonomy/categories.html +++ b/apps/taxonomy/templates/taxonomy/categories.html @@ -15,6 +15,8 @@

Taxonomy

class="px-4 py-2 text-sm font-medium text-slate-500 hover:text-slate-700 border-b-2 border-transparent -mb-px">Projects Categories + Entry Types Groups +
+ ← Back to entry types +

Edit entry type

+
+ +
+
+ {% csrf_token %} + {% for field in form %} + {% include "components/_form_field.html" %} + {% endfor %} +
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/apps/taxonomy/templates/taxonomy/entry_types.html b/apps/taxonomy/templates/taxonomy/entry_types.html new file mode 100644 index 0000000..33c6c85 --- /dev/null +++ b/apps/taxonomy/templates/taxonomy/entry_types.html @@ -0,0 +1,129 @@ +{% extends "base.html" %} +{% block title %}Entry Types — SCD Reporting{% endblock %} + +{% block content %} +
+ +
+

Taxonomy

+

Manage the labels used to classify effort entries.

+
+ +
+ +
+ + ↓ Export JSON + + +
+
+ + {# Inline import form — hidden by default #} + + +
+ + + + + + + + + + + + {% for t in entry_types %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
#NameShort CodeStatusActions
{{ t.sort_order }}{{ t.name }}{{ t.short_code|default:"—" }} + {% if t.is_active %} + Active + {% else %} + Inactive + {% endif %} + + Edit +
+ No entry types yet. Add one below. +
+
+ +
+
+

Add entry type

+
+
+ {% csrf_token %} +
+ {% for field in form %} + {% include "components/_form_field.html" %} + {% endfor %} +
+ +
+
+ +
+ + +{% endblock %} diff --git a/apps/taxonomy/templates/taxonomy/groups.html b/apps/taxonomy/templates/taxonomy/groups.html index 76d9707..fd6962c 100644 --- a/apps/taxonomy/templates/taxonomy/groups.html +++ b/apps/taxonomy/templates/taxonomy/groups.html @@ -15,6 +15,8 @@

Taxonomy

class="px-4 py-2 text-sm font-medium text-slate-500 hover:text-slate-700 border-b-2 border-transparent -mb-px">Projects Categories + Entry Types Groups Taxonomy class="px-4 py-2 text-sm font-medium text-slate-500 hover:text-slate-700 border-b-2 border-transparent -mb-px">Projects Categories + Entry Types Groups Taxonomy class="px-4 py-2 text-sm font-medium text-scd-primary border-b-2 border-scd-primary -mb-px">Projects Categories + Entry Types Groups /edit/', views.ProjectEditView.as_view(), name='project-edit'), path('categories/', views.CategoryManageView.as_view(), name='categories'), path('categories//edit/', views.CategoryEditView.as_view(), name='category-edit'), + path('entry-types/', views.EntryTypeManageView.as_view(), name='entry-types'), + path('entry-types//edit/', views.EntryTypeEditView.as_view(), name='entry-type-edit'), path('groups/', views.WorkGroupManageView.as_view(), name='groups'), path('groups//edit/', views.WorkGroupEditView.as_view(), name='group-edit'), path('lab-priorities/', views.LabPriorityManageView.as_view(), name='lab-priorities'), diff --git a/apps/taxonomy/views.py b/apps/taxonomy/views.py index 9ae4735..6d4968b 100644 --- a/apps/taxonomy/views.py +++ b/apps/taxonomy/views.py @@ -11,8 +11,8 @@ from apps.accounts.permissions import TaxonomyEditorRequiredMixin as AdminRequiredMixin -from .forms import CategoryForm, LabPriorityForm, ProjectForm, WorkGroupForm -from .models import Category, LabPriority, Project, Tag, WorkGroup +from .forms import CategoryForm, EntryTypeForm, LabPriorityForm, ProjectForm, WorkGroupForm +from .models import Category, EntryType, LabPriority, Project, Tag, WorkGroup class ProjectManageView(AdminRequiredMixin, View): @@ -79,6 +79,38 @@ def form_valid(self, form): return super().form_valid(form) +class EntryTypeManageView(AdminRequiredMixin, View): + template_name = 'taxonomy/entry_types.html' + + def _ctx(self, form=None): + return { + 'entry_types': EntryType.objects.order_by('sort_order', 'name'), + 'form': form or EntryTypeForm(), + } + + def get(self, request): + return render(request, self.template_name, self._ctx()) + + def post(self, request): + form = EntryTypeForm(request.POST) + if form.is_valid(): + obj = form.save() + messages.success(request, f'Entry Type "{obj.name}" added.') + return redirect('taxonomy:entry-types') + return render(request, self.template_name, self._ctx(form)) + + +class EntryTypeEditView(AdminRequiredMixin, UpdateView): + model = EntryType + form_class = EntryTypeForm + template_name = 'taxonomy/entry_type_form.html' + success_url = reverse_lazy('taxonomy:entry-types') + + def form_valid(self, form): + messages.success(self.request, f'Entry Type "{form.instance.name}" saved.') + return super().form_valid(form) + + class WorkGroupManageView(AdminRequiredMixin, View): template_name = 'taxonomy/groups.html' @@ -161,6 +193,7 @@ def rows(qs): 'exported_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'projects': rows(Project.objects.order_by('sort_order', 'name')), 'categories': rows(Category.objects.order_by('sort_order', 'name')), + 'entry_types': rows(EntryType.objects.order_by('sort_order', 'name')), 'groups': rows(WorkGroup.objects.order_by('sort_order', 'name')), 'lab_priorities': rows(LabPriority.objects.order_by('sort_order', 'name')), } @@ -177,6 +210,7 @@ class TaxonomyImportView(AdminRequiredMixin, View): TABLES = { 'projects': Project, 'categories': Category, + 'entry_types': EntryType, 'groups': WorkGroup, 'lab_priorities': LabPriority, } diff --git a/tests/test_entries.py b/tests/test_entries.py index c614dbb..b1deb5b 100644 --- a/tests/test_entries.py +++ b/tests/test_entries.py @@ -161,6 +161,40 @@ def test_sets_author_to_current_user(self, client, user, project, category): }) assert WorkItem.objects.get(title='Authored').author == user + def test_post_with_entry_type_sets_it(self, client, user, project, category): + from apps.taxonomy.models import EntryType + et = EntryType.objects.create(name='Weekly Report', slug='weekly-report') + client.force_login(user) + today = date.today() + client.post(reverse('entries:create'), { + 'title': 'Typed entry', + 'project': project.pk, + 'category': category.pk, + 'entry_type': et.pk, + 'period_kind': 'week', + 'period_start': (today - timedelta(days=today.weekday())).isoformat(), + 'period_end': (today - timedelta(days=today.weekday()) + timedelta(days=6)).isoformat(), + 'description': 'desc', + 'tags_input': '', + }) + assert WorkItem.objects.get(title='Typed entry').entry_type == et + + def test_entry_type_is_optional(self, client, user, project, category): + client.force_login(user) + today = date.today() + resp = client.post(reverse('entries:create'), { + 'title': 'No type', + 'project': project.pk, + 'category': category.pk, + 'period_kind': 'week', + 'period_start': (today - timedelta(days=today.weekday())).isoformat(), + 'period_end': (today - timedelta(days=today.weekday()) + timedelta(days=6)).isoformat(), + 'description': 'desc', + 'tags_input': '', + }) + assert resp.status_code == 302 + assert WorkItem.objects.get(title='No type').entry_type is None + def test_post_with_tags_increments_use_count(self, client, user, project, category): tag = Tag.objects.create(name='mytag') client.force_login(user) diff --git a/tests/test_reports.py b/tests/test_reports.py index 4186c63..71362fb 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -11,7 +11,7 @@ from apps.accounts.models import User from apps.entries.models import WorkItem -from apps.taxonomy.models import Category, Project, WorkGroup +from apps.taxonomy.models import Category, EntryType, Project, WorkGroup @pytest.fixture @@ -102,6 +102,19 @@ def test_author_email_filter(self, client, admin_user, entry): resp = client.post(reverse('reports:preview'), {'author_email': 'nomatch@x.com'}) assert b'Test entry' not in resp.content + def test_entry_type_filter(self, client, admin_user, entry, db): + weekly = EntryType.objects.create(name='Weekly Report', slug='weekly-report') + milestone = EntryType.objects.create(name='Milestone', slug='milestone') + entry.entry_type = weekly + entry.save(update_fields=['entry_type']) + client.force_login(admin_user) + # Matching type shows the entry… + resp = client.post(reverse('reports:preview'), {'entry_type': weekly.pk}) + assert b'Test entry' in resp.content + # …a different type filters it out. + resp = client.post(reverse('reports:preview'), {'entry_type': milestone.pk}) + assert b'Test entry' not in resp.content + def test_no_match_shows_empty_message(self, client, admin_user): client.force_login(admin_user) resp = client.post(reverse('reports:preview'), {'author_email': 'nobody@nowhere.com'}) diff --git a/tests/test_taxonomy.py b/tests/test_taxonomy.py index e1c0c13..9b390a6 100644 --- a/tests/test_taxonomy.py +++ b/tests/test_taxonomy.py @@ -1,7 +1,7 @@ import pytest from django.contrib.auth import get_user_model -from apps.taxonomy.models import Category, Project, Tag +from apps.taxonomy.models import Category, EntryType, Project, Tag User = get_user_model() @@ -91,6 +91,27 @@ def test_create_project_via_post(client, admin_user): assert Project.objects.filter(name='Test').exists() +@pytest.mark.django_db +def test_entry_types_page_requires_admin(client, regular_user): + client.force_login(regular_user) + assert client.get('/taxonomy/entry-types/').status_code == 403 + + +@pytest.mark.django_db +def test_entry_types_page_accessible_to_admin(client, admin_user): + client.force_login(admin_user) + assert client.get('/taxonomy/entry-types/').status_code == 200 + + +@pytest.mark.django_db +def test_create_entry_type_via_post(client, admin_user): + client.force_login(admin_user) + resp = client.post('/taxonomy/entry-types/', {'name': 'Milestone', 'short_code': 'MS', + 'is_active': True, 'sort_order': 5}) + assert resp.status_code == 302 + assert EntryType.objects.filter(name='Milestone').exists() + + @pytest.mark.django_db def test_edit_project(client, admin_user): p = Project.objects.create(name='OldName') @@ -119,8 +140,10 @@ def test_seed_taxonomy_creates_expected_data(): call_command('seed_taxonomy', verbosity=0) assert Project.objects.count() == 5 assert Category.objects.count() == 4 + assert EntryType.objects.count() == 3 assert Project.objects.filter(name='MicroBooNE').exists() assert Category.objects.filter(name='Outreach').exists() + assert EntryType.objects.filter(name='Weekly Report').exists() # ── Tag autocomplete content ──────────────────────────────────────────────────