From 0a73a6e001fbc9c2389b2950723a4b9fc66ceb0a Mon Sep 17 00:00:00 2001 From: Andrew Norman Date: Thu, 25 Jun 2026 13:35:34 -0500 Subject: [PATCH] Allow multiple projects/categories/lab priorities per entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert WorkItem.project / category / lab_priority from single foreign keys to many-to-many relations (projects / categories / lab_priorities) so an entry can be classified under several at once, and let the Reports page filter by multiple values (match ANY). A data migration copies each existing single value into the new M2M before dropping the old columns, so no data is lost. - model + migration 0008: FK -> M2M with forward/backward data copy. - entry form: multi-select widgets; >=1 project and >=1 category still required; lab priorities optional. Field order is now Projects, Categories, Lab Priorities, Group. - reports: ModelMultipleChoiceFilter (OR) for the three menus. - displays (detail/list/manage/reports/dashboard/exporters/PDF/AI) join multiple names with ", ". - JSON API: backward-compatible — accepts a single slug or a list, and returns plural lists plus the legacy singular keys (first value). - audit: project/category/lab_priority tracked via m2m_changed handlers. - tests: fixtures updated for M2M; new multi-select form, reports filter, and API back-compat coverage (205 passing). Also nudges the markdown cheat-sheet panel on the entry form to align with the top of the form card (moves the page header above the columns). Closes fermitools/SCD-Reporting#15 Co-Authored-By: Claude Opus 4.8 --- apps/audit/signals.py | 39 +++++++- apps/core/templates/core/api.html | 12 ++- apps/core/templates/core/dashboard.html | 2 +- apps/core/views.py | 7 +- apps/entries/admin.py | 14 ++- apps/entries/api_views.py | 56 ++++++++--- apps/entries/forms.py | 26 ++++-- .../migrations/0009_multiselect_taxonomy.py | 85 +++++++++++++++++ apps/entries/models.py | 13 +-- apps/entries/templates/entries/detail.html | 14 ++- apps/entries/templates/entries/form.html | 86 +++++++---------- apps/entries/templates/entries/list.html | 4 +- apps/entries/templates/entries/manage.html | 2 +- apps/entries/views.py | 17 ++-- apps/reports/exporters.py | 8 +- apps/reports/filters.py | 15 +-- apps/reports/templates/reports/index.html | 54 +++++------ .../templates/reports/partials/_preview.html | 4 +- apps/reports/views.py | 4 +- tests/test_accounts_permissions.py | 6 +- tests/test_api_entries.py | 92 +++++++++++++++++++ tests/test_audit.py | 14 ++- tests/test_entries.py | 84 +++++++++++++---- tests/test_reports.py | 39 +++++++- 24 files changed, 519 insertions(+), 178 deletions(-) create mode 100644 apps/entries/migrations/0009_multiselect_taxonomy.py create mode 100644 tests/test_api_entries.py diff --git a/apps/audit/signals.py b/apps/audit/signals.py index e3434a3..d335131 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', 'entry_type_id', 'group_id', + 'title', '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', @@ -92,6 +92,43 @@ def _workitem_tags_changed(sender, instance, action, pk_set, **kwargs): log_event(action='update', obj=instance, changes={'tags_cleared': True}) +# ── WorkItem taxonomy m2m changes (projects / categories / lab priorities) ──── + +def _log_taxonomy_m2m(instance, action, pk_set, model, label): + if action not in ('post_add', 'post_remove', 'post_clear'): + return + if not isinstance(instance, WorkItem): + return # guard: m2m_changed fires for both sides of the relation + if action == 'post_add' and pk_set: + names = sorted(model.objects.filter(pk__in=pk_set).values_list('name', flat=True)) + if names: + log_event(action='update', obj=instance, changes={f'{label}_added': names}) + elif action == 'post_remove' and pk_set: + names = sorted(model.objects.filter(pk__in=pk_set).values_list('name', flat=True)) + if names: + log_event(action='update', obj=instance, changes={f'{label}_removed': names}) + elif action == 'post_clear': + log_event(action='update', obj=instance, changes={f'{label}_cleared': True}) + + +@receiver(m2m_changed, sender=WorkItem.projects.through) +def _workitem_projects_changed(sender, instance, action, pk_set, **kwargs): + from apps.taxonomy.models import Project + _log_taxonomy_m2m(instance, action, pk_set, Project, 'projects') + + +@receiver(m2m_changed, sender=WorkItem.categories.through) +def _workitem_categories_changed(sender, instance, action, pk_set, **kwargs): + from apps.taxonomy.models import Category + _log_taxonomy_m2m(instance, action, pk_set, Category, 'categories') + + +@receiver(m2m_changed, sender=WorkItem.lab_priorities.through) +def _workitem_lab_priorities_changed(sender, instance, action, pk_set, **kwargs): + from apps.taxonomy.models import LabPriority + _log_taxonomy_m2m(instance, action, pk_set, LabPriority, 'lab_priorities') + + # ── Auth events ─────────────────────────────────────────────────────────────── @receiver(user_logged_in) diff --git a/apps/core/templates/core/api.html b/apps/core/templates/core/api.html index 1c7f1b8..cc7216b 100644 --- a/apps/core/templates/core/api.html +++ b/apps/core/templates/core/api.html @@ -59,13 +59,13 @@

REST API

{
   "title":        "string (required)",
   "description":  "string (required)",
-  "project":      "slug or id (required)",
-  "category":     "slug or id (required)",
+  "projects":     ["slug or id", …]  (required; accepts a single value too),
+  "categories":   ["slug or id", …]  (required; accepts a single value too),
   "period_kind":  "today|week|fortnight|month|custom",
   "period_start": "YYYY-MM-DD (required)",
   "period_end":   "YYYY-MM-DD (required)",
   "group":        "slug or id (optional)",
-  "lab_priority": "slug or id (optional)",
+  "lab_priorities": ["slug or id", …]  (optional),
   "tags":         ["tag1", "tag2"],
   "is_private":           false,
   "is_critical":          false,
@@ -79,8 +79,10 @@ 

REST API

{
   "id":           42,
   "title":        "Weekly status",
-  "project":      "project-slug",
-  "category":     "category-slug",
+  "projects":     ["project-slug"],
+  "categories":   ["category-slug"],
+  "project":      "project-slug",   (legacy: first selected)
+  "category":     "category-slug",  (legacy: first selected)
   "period_kind":  "week",
   "period_start": "2026-05-26",
   "period_end":   "2026-06-01",
diff --git a/apps/core/templates/core/dashboard.html b/apps/core/templates/core/dashboard.html
index 863643a..4346621 100644
--- a/apps/core/templates/core/dashboard.html
+++ b/apps/core/templates/core/dashboard.html
@@ -54,7 +54,7 @@ 

Recent Entries

{{ entry.title }}

- {{ entry.project.name }} · {{ entry.category.name }} · + {% for p in entry.projects.all %}{{ p.name }}{% if not forloop.last %}, {% endif %}{% endfor %} · {% for c in entry.categories.all %}{{ c.name }}{% if not forloop.last %}, {% endif %}{% endfor %} · {{ entry.period_start|date:"M j" }}–{{ entry.period_end|date:"M j, Y" }}

diff --git a/apps/core/views.py b/apps/core/views.py index ca6511a..40212e8 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -51,8 +51,7 @@ def get_context_data(self, **kwargs): ctx['recent_entries'] = ( WorkItem.objects .filter(author=self.request.user) - .select_related('project', 'category') - .prefetch_related('tags') + .prefetch_related('projects', 'categories', 'tags') .order_by('-period_end', '-created_at')[:20] ) ctx['prompt_form'] = AIPromptConfigForm(instance=AIPromptConfig.for_user(self.request.user)) @@ -71,8 +70,8 @@ def post(self, request): }) qs = (WorkItem.objects .filter(author=request.user, pk__in=selected_ids) - .select_related('author', 'project', 'category') - .prefetch_related('tags')) + .select_related('author') + .prefetch_related('projects', 'categories', 'tags')) count = qs.count() if count == 0: return render(request, 'core/partials/_dashboard_summary.html', { diff --git a/apps/entries/admin.py b/apps/entries/admin.py index 480fec7..2919e92 100644 --- a/apps/entries/admin.py +++ b/apps/entries/admin.py @@ -5,11 +5,19 @@ @admin.register(WorkItem) class WorkItemAdmin(admin.ModelAdmin): - list_display = ('title', 'author', 'project', 'category', 'entry_type', + list_display = ('title', 'author', 'projects_list', 'categories_list', 'entry_type', 'period_start', 'period_end', 'is_private', 'created_at') - list_filter = ('project', 'category', 'entry_type', 'is_private') + list_filter = ('projects', 'categories', 'entry_type', 'is_private') search_fields = ('title', 'description', 'author__email', 'author__display_name') raw_id_fields = ('author',) - filter_horizontal = ('tags',) + filter_horizontal = ('projects', 'categories', 'lab_priorities', 'tags') date_hierarchy = 'period_start' readonly_fields = ('created_at', 'updated_at') + + @admin.display(description='Projects') + def projects_list(self, obj): + return ', '.join(p.name for p in obj.projects.all()) + + @admin.display(description='Categories') + def categories_list(self, obj): + return ', '.join(c.name for c in obj.categories.all()) diff --git a/apps/entries/api_views.py b/apps/entries/api_views.py index e16d8fa..f4e0e40 100644 --- a/apps/entries/api_views.py +++ b/apps/entries/api_views.py @@ -51,6 +51,33 @@ def _resolve_fk(model, value, allow_blank=False): return None, f'{model.__name__} "{value}" not found or inactive.' +def _resolve_fk_list(model, value, required=True): + """Resolve a slug/PK or list of them to a list of instances. + + Accepts a single value (backward-compatible) or a list. Returns + (list_of_instances, error_str). + """ + if value in (None, '', []): + if required: + return None, f'{model.__name__} is required.' + return [], None + values = value if isinstance(value, list) else [value] + instances = [] + for v in values: + inst, err = _resolve_fk(model, v) + if err: + return None, err + instances.append(inst) + return instances, None + + +def _get_multi(data, plural_key, singular_key): + """Read a list field, preferring the plural key but accepting the legacy singular one.""" + if plural_key in data: + return data[plural_key] + return data.get(singular_key) + + def _coerce_bool(val, default=False): if isinstance(val, bool): return val @@ -120,21 +147,23 @@ def post(self, request): if parsed_start and parsed_end and parsed_end < parsed_start: errors['period_end'] = 'period_end must be on or after period_start.' - project, err = _resolve_fk(Project, data.get('project')) + projects, err = _resolve_fk_list(Project, _get_multi(data, 'projects', 'project')) if err: - errors['project'] = err + errors['projects'] = err - category, err = _resolve_fk(Category, data.get('category')) + categories, err = _resolve_fk_list(Category, _get_multi(data, 'categories', 'category')) if err: - errors['category'] = err + errors['categories'] = err group, err = _resolve_fk(WorkGroup, data.get('group'), allow_blank=True) if err: errors['group'] = err - lab_priority, err = _resolve_fk(LabPriority, data.get('lab_priority'), allow_blank=True) + lab_priorities, err = _resolve_fk_list( + LabPriority, _get_multi(data, 'lab_priorities', 'lab_priority'), required=False, + ) if err: - errors['lab_priority'] = err + errors['lab_priorities'] = err if errors: return JsonResponse({'errors': errors}, status=400) @@ -144,10 +173,7 @@ def post(self, request): author=user, title=title, description=description, - project=project, - category=category, group=group, - lab_priority=lab_priority, period_kind=period_kind, period_start=parsed_start, period_end=parsed_end, @@ -157,6 +183,9 @@ def post(self, request): highlight_stars=_coerce_int(data.get('highlight_stars')), is_division_head_only=_coerce_bool(data.get('is_division_head_only')), ) + entry.projects.set(projects) + entry.categories.set(categories) + entry.lab_priorities.set(lab_priorities) tag_names = data.get('tags', []) if isinstance(tag_names, list): @@ -171,11 +200,16 @@ def post(self, request): entry.tags.set(tags) from django.urls import reverse + project_slugs = [p.slug for p in projects] + category_slugs = [c.slug for c in categories] return JsonResponse({ 'id': entry.pk, 'title': entry.title, - 'project': entry.project.slug, - 'category': entry.category.slug, + 'projects': project_slugs, + 'categories': category_slugs, + # Legacy single-value keys (first selected) for backward compatibility. + 'project': project_slugs[0] if project_slugs else None, + 'category': category_slugs[0] if category_slugs else None, 'period_kind': entry.period_kind, 'period_start': entry.period_start.isoformat(), 'period_end': entry.period_end.isoformat(), diff --git a/apps/entries/forms.py b/apps/entries/forms.py index 9e563b8..8825a08 100644 --- a/apps/entries/forms.py +++ b/apps/entries/forms.py @@ -12,7 +12,7 @@ class WorkItemForm(forms.ModelForm): class Meta: model = WorkItem fields = [ - 'title', 'project', 'category', 'entry_type', 'group', 'lab_priority', + 'title', 'projects', 'categories', 'lab_priorities', 'entry_type', 'group', 'period_kind', 'period_start', 'period_end', 'description', 'is_private', 'is_critical', 'is_highlight', 'highlight_stars', 'is_division_head_only', @@ -27,15 +27,23 @@ class Meta: 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') - self.fields['lab_priority'].required = False + self.fields['projects'].queryset = Project.objects.filter(is_active=True).order_by('sort_order', 'name') + self.fields['categories'].queryset = Category.objects.filter(is_active=True).order_by('sort_order', 'name') + # At least one project and one category remain required (ModelMultipleChoiceField + # is required by default — an empty multi-select fails validation). + self.fields['lab_priorities'].queryset = LabPriority.objects.filter(is_active=True).order_by('sort_order', 'name') + self.fields['lab_priorities'].required = False + 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['highlight_stars'].required = False + + # Style the multi-select widgets to match the rest of the form. + _multi_cls = ('w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm ' + 'focus:border-scd-primary focus:ring-1 focus:ring-scd-primary focus:outline-none') + for _name in ('projects', 'categories', 'lab_priorities'): + self.fields[_name].widget.attrs.update({'class': _multi_cls, 'size': 5}) if self.instance.pk: self.fields['tags_input'].initial = ','.join( self.instance.tags.values_list('name', flat=True) diff --git a/apps/entries/migrations/0009_multiselect_taxonomy.py b/apps/entries/migrations/0009_multiselect_taxonomy.py new file mode 100644 index 0000000..f329d15 --- /dev/null +++ b/apps/entries/migrations/0009_multiselect_taxonomy.py @@ -0,0 +1,85 @@ +# Generated by Django 5.2.14 on 2026-06-25 17:36 +# +# Converts WorkItem.project / category / lab_priority (single FKs) to +# many-to-many relations (projects / categories / lab_priorities). The new +# M2M fields are added first, existing single values are copied across, and +# only then are the old FK columns removed — so no data is lost. + +from django.db import migrations, models + + +def copy_fk_to_m2m(apps, schema_editor): + WorkItem = apps.get_model("entries", "WorkItem") + for item in WorkItem.objects.all(): + if item.project_id: + item.projects.add(item.project_id) + if item.category_id: + item.categories.add(item.category_id) + if item.lab_priority_id: + item.lab_priorities.add(item.lab_priority_id) + + +def copy_m2m_to_fk(apps, schema_editor): + # Reverse: collapse each M2M back to a single value (first by sort order). + WorkItem = apps.get_model("entries", "WorkItem") + for item in WorkItem.objects.all(): + project = item.projects.first() + category = item.categories.first() + lab_priority = item.lab_priorities.first() + item.project = project + item.category = category + item.lab_priority = lab_priority + item.save(update_fields=["project", "category", "lab_priority"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("entries", "0008_workitem_entry_type_and_more"), + ("taxonomy", "0003_labpriority"), + ] + + operations = [ + migrations.AddField( + model_name="workitem", + name="categories", + field=models.ManyToManyField( + related_name="work_items", to="taxonomy.category" + ), + ), + migrations.AddField( + model_name="workitem", + name="lab_priorities", + field=models.ManyToManyField( + blank=True, related_name="work_items", to="taxonomy.labpriority" + ), + ), + migrations.AddField( + model_name="workitem", + name="projects", + field=models.ManyToManyField( + related_name="work_items", to="taxonomy.project" + ), + ), + migrations.RunPython(copy_fk_to_m2m, copy_m2m_to_fk), + migrations.RemoveIndex( + model_name="workitem", + name="entries_wor_project_814625_idx", + ), + migrations.RemoveIndex( + model_name="workitem", + name="entries_wor_categor_3c98a4_idx", + ), + migrations.RemoveField( + model_name="workitem", + name="category", + ), + migrations.RemoveField( + model_name="workitem", + name="lab_priority", + ), + migrations.RemoveField( + model_name="workitem", + name="project", + ), + ] diff --git a/apps/entries/models.py b/apps/entries/models.py index fba1411..dead9e3 100644 --- a/apps/entries/models.py +++ b/apps/entries/models.py @@ -17,14 +17,12 @@ class PeriodKind(models.TextChoices): related_name='work_items', ) title = models.CharField(max_length=200) - project = models.ForeignKey( + projects = models.ManyToManyField( 'taxonomy.Project', - on_delete=models.PROTECT, related_name='work_items', ) - category = models.ForeignKey( + categories = models.ManyToManyField( 'taxonomy.Category', - on_delete=models.PROTECT, related_name='work_items', ) entry_type = models.ForeignKey( @@ -39,10 +37,9 @@ class PeriodKind(models.TextChoices): on_delete=models.PROTECT, related_name='work_items', ) - lab_priority = models.ForeignKey( + lab_priorities = models.ManyToManyField( 'taxonomy.LabPriority', - null=True, blank=True, - on_delete=models.PROTECT, + blank=True, related_name='work_items', ) tags = models.ManyToManyField('taxonomy.Tag', blank=True) @@ -70,8 +67,6 @@ class Meta: ordering = ['-period_end', '-created_at'] indexes = [ 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 bcf2b1a..c92899c 100644 --- a/apps/entries/templates/entries/detail.html +++ b/apps/entries/templates/entries/detail.html @@ -48,13 +48,19 @@

{{ object.title }}

{{ object.author.display_name|default:object.author.email }}
-
Project
-
{{ object.project.name }}
+
Projects
+
{% for p in object.projects.all %}{{ p.name }}{% if not forloop.last %}, {% endif %}{% endfor %}
-
Category
-
{{ object.category.name }}
+
Categories
+
{% for c in object.categories.all %}{{ c.name }}{% if not forloop.last %}, {% endif %}{% endfor %}
+ {% if object.lab_priorities.all %} +
+
Lab Priorities
+
{% for lp in object.lab_priorities.all %}{{ lp.name }}{% if not forloop.last %}, {% endif %}{% endfor %}
+
+ {% endif %} {% if object.entry_type %}
Entry Type
diff --git a/apps/entries/templates/entries/form.html b/apps/entries/templates/entries/form.html index 0aaf5a2..88a2a16 100644 --- a/apps/entries/templates/entries/form.html +++ b/apps/entries/templates/entries/form.html @@ -2,19 +2,19 @@ {% block title %}{% if object %}Edit Entry{% else %}New Entry{% endif %} — SCD Reporting{% endblock %} {% block content %} +
+ + ← {% if object %}Back to entry{% else %}My Entries{% endif %} + +

+ {% if object %}Edit Entry{% else %}New Entry{% endif %} +

+
+
-
- - ← {% if object %}Back to entry{% else %}My Entries{% endif %} - -

- {% if object %}Edit Entry{% else %}New Entry{% endif %} -

-
-
{% csrf_token %} @@ -42,41 +42,35 @@

{% endfor %}

- {# ── Project / Category / Entry Type / Group / Lab Priority ───────────── #} + {# ── Projects / Categories / Lab Priorities / Entry Type / Group ──────── #}
-
-
+
+ + {{ form.lab_priorities }} +

Hold Ctrl (⌘ on Mac) to select more than one.

+ {% for error in form.lab_priorities.errors %}

{{ error }}

{% endfor %}
@@ -116,24 +110,6 @@

{{ error }}

{% endfor %}

-
- - - {% for error in form.lab_priority.errors %} -

{{ error }}

- {% endfor %} -
{# ── Period ────────────────────────────────────────────────────────────── #} diff --git a/apps/entries/templates/entries/list.html b/apps/entries/templates/entries/list.html index f63aae8..9020599 100644 --- a/apps/entries/templates/entries/list.html +++ b/apps/entries/templates/entries/list.html @@ -78,9 +78,9 @@

My Entries

- {{ entry.project.name }} + {% for p in entry.projects.all %}{{ p.name }}{% if not forloop.last %}, {% endif %}{% endfor %} · - {{ entry.category.name }} + {% for c in entry.categories.all %}{{ c.name }}{% if not forloop.last %}, {% endif %}{% endfor %} Manage Entries class="font-medium text-slate-800 hover:text-scd-primary">{{ entry.title }} {{ entry.author.email }} - {{ entry.project.name }} + {% for p in entry.projects.all %}{{ p.name }}{% if not forloop.last %}, {% endif %}{% endfor %} {% if entry.is_archived %} Archived diff --git a/apps/entries/views.py b/apps/entries/views.py index 76fac1c..4f1b3c2 100644 --- a/apps/entries/views.py +++ b/apps/entries/views.py @@ -27,18 +27,19 @@ class EntryListView(LoginRequiredMixin, ListView): def get_queryset(self): qs = (WorkItem.objects .filter(author=self.request.user, is_archived=False) - .select_related('project', 'category') - .prefetch_related('tags')) + .prefetch_related('projects', 'categories', 'tags')) project_id = self.request.GET.get('project') if project_id: - qs = qs.filter(project_id=project_id) + qs = qs.filter(projects__id=project_id) q = self.request.GET.get('q', '').strip() if q: qs = qs.filter( Q(title__icontains=q) | Q(description__icontains=q) | Q(tags__name__icontains=q) - ).distinct() + ) + if project_id or q: + qs = qs.distinct() return qs def get_context_data(self, **kwargs): @@ -98,7 +99,7 @@ class EntryDetailView(LoginRequiredMixin, DetailView): template_name = 'entries/detail.html' def get_queryset(self): - qs = WorkItem.objects.prefetch_related('tags') + qs = WorkItem.objects.prefetch_related('projects', 'categories', 'lab_priorities', 'tags') user = self.request.user if user.is_scd_admin or user.is_division_head: pass # full access including division-head-only entries @@ -212,8 +213,8 @@ class EntryManageView(EntryManagerRequiredMixin, ListView): def get_queryset(self): from apps.accounts.models import User qs = (WorkItem.objects - .select_related('author', 'project', 'category') - .prefetch_related('tags')) + .select_related('author') + .prefetch_related('projects', 'categories', 'tags')) user = self.request.user if not (user.is_scd_admin or user.is_division_head): qs = qs.filter(is_division_head_only=False) @@ -225,7 +226,7 @@ def get_queryset(self): qs = qs.filter(author__email__icontains=author) project = self.request.GET.get('project', '').strip() if project: - qs = qs.filter(project_id=project) + qs = qs.filter(projects__id=project).distinct() show_archived = self.request.GET.get('archived') == '1' if show_archived: qs = qs.filter(is_archived=True) diff --git a/apps/reports/exporters.py b/apps/reports/exporters.py index a0b55c4..2975955 100644 --- a/apps/reports/exporters.py +++ b/apps/reports/exporters.py @@ -32,7 +32,9 @@ def _filename(fmt: str) -> str: def _base_qs(qs): - return qs.select_related('author', 'project', 'category').prefetch_related('tags') + return qs.select_related('author').prefetch_related( + 'projects', 'categories', 'tags', + ) def _rows(qs): @@ -41,8 +43,8 @@ def _rows(qs): 'id': item.pk, 'author': item.author.email, 'title': item.title, - 'project': item.project.name, - 'category': item.category.name, + 'project': ', '.join(p.name for p in item.projects.all()), + 'category': ', '.join(c.name for c in item.categories.all()), 'group': item.group.name if item.group else '', 'period_kind': item.get_period_kind_display(), 'period_start': item.period_start.isoformat(), diff --git a/apps/reports/filters.py b/apps/reports/filters.py index b6a298a..4584fd2 100644 --- a/apps/reports/filters.py +++ b/apps/reports/filters.py @@ -28,25 +28,28 @@ class WorkItemFilter(django_filters.FilterSet): label='Activity Group', empty_label='All groups', ) - project = django_filters.ModelChoiceFilter( + projects = django_filters.ModelMultipleChoiceFilter( + field_name='projects', queryset=Project.objects.filter(is_active=True).order_by('sort_order', 'name'), label='Project', - empty_label='All projects', + conjoined=False, # match ANY of the selected projects ) - category = django_filters.ModelChoiceFilter( + categories = django_filters.ModelMultipleChoiceFilter( + field_name='categories', queryset=Category.objects.filter(is_active=True).order_by('sort_order', 'name'), label='Category', - empty_label='All categories', + conjoined=False, ) entry_type = django_filters.ModelChoiceFilter( queryset=EntryType.objects.filter(is_active=True).order_by('sort_order', 'name'), label='Entry Type', empty_label='All entry types', ) - lab_priority = django_filters.ModelChoiceFilter( + lab_priorities = django_filters.ModelMultipleChoiceFilter( + field_name='lab_priorities', queryset=LabPriority.objects.filter(is_active=True).order_by('sort_order', 'name'), label='Lab Priority', - empty_label='All lab priorities', + conjoined=False, ) period_after = django_filters.DateFilter( field_name='period_start', diff --git a/apps/reports/templates/reports/index.html b/apps/reports/templates/reports/index.html index 56b3e39..0912d8a 100644 --- a/apps/reports/templates/reports/index.html +++ b/apps/reports/templates/reports/index.html @@ -173,57 +173,57 @@

Filters {% endif %} - {# Project #} + {# Projects (multi-select; none selected = all) #}
+ {% with selp=filter.form.projects.value|default_if_none:'' %} {% if user.is_functional_lead %} {% with first_project=project_scope.first %} {% if project_scope.count == 1 %} {# Locked to the single managed project #} - +
{{ first_project.name }}
{% else %} - {# Dropdown limited to managed projects #} - +

Select one or more (none = all).

{% endif %} {% endwith %} {% else %} - {# Full dropdown for auditors/admins/division heads/group leaders #} - +

Select one or more (none = all).

{% endif %} + {% endwith %}
- {# Category #} + {# Categories (multi-select; none selected = all) #}
- +

Select one or more (none = all).

+ {% endwith %}
{# Entry Type #} @@ -241,19 +241,19 @@

Filters - {# Lab Priority #} + {# Lab Priorities (multi-select; none selected = all) #}
- +

Select one or more (none = all).

+ {% endwith %}
{# Period quick-select + date range #} diff --git a/apps/reports/templates/reports/partials/_preview.html b/apps/reports/templates/reports/partials/_preview.html index 686389d..369bfe4 100644 --- a/apps/reports/templates/reports/partials/_preview.html +++ b/apps/reports/templates/reports/partials/_preview.html @@ -72,8 +72,8 @@ {{ entry.title }} - {{ entry.project.name }} - {{ entry.category.name }} + {% for p in entry.projects.all %}{{ p.name }}{% if not forloop.last %}, {% endif %}{% endfor %} + {% for c in entry.categories.all %}{{ c.name }}{% if not forloop.last %}, {% endif %}{% endfor %} {% for tag in entry.tags.all %} #{{ tag.name }} diff --git a/apps/reports/views.py b/apps/reports/views.py index b78f07f..e6a2f9c 100644 --- a/apps/reports/views.py +++ b/apps/reports/views.py @@ -41,12 +41,12 @@ def _get_project_scope(user): def _filtered_qs(data, group_scope=None, project_scope=None, user=None): - qs = WorkItem.objects.select_related('author', 'project', 'category').prefetch_related('tags') + qs = WorkItem.objects.select_related('author').prefetch_related('projects', 'categories', 'tags') if group_scope is not None: # Prefer explicit WorkItem.group; fall back to author.group only when entry has no group set. qs = qs.filter(Q(group__in=group_scope) | Q(group__isnull=True, author__group__in=group_scope)) if project_scope is not None: - qs = qs.filter(project__in=project_scope) + qs = qs.filter(projects__in=project_scope).distinct() if user is not None and not (user.is_scd_admin or user.is_division_head): qs = qs.filter(is_division_head_only=False) show_archived = data.get('show_archived') == '1' diff --git a/tests/test_accounts_permissions.py b/tests/test_accounts_permissions.py index 812927b..a175702 100644 --- a/tests/test_accounts_permissions.py +++ b/tests/test_accounts_permissions.py @@ -151,13 +151,15 @@ def test_delete_user_with_entries_is_blocked(client, make_user, db): project = Project.objects.create(name='P1', slug='p1') category = Category.objects.create(name='C1', slug='c1') today = date.today() - WorkItem.objects.create( - author=target, title='entry', project=project, category=category, + item = WorkItem.objects.create( + author=target, title='entry', period_kind='week', period_start=today - timedelta(days=today.weekday()), period_end=today - timedelta(days=today.weekday()) + timedelta(days=6), description='desc', ) + item.projects.set([project]) + item.categories.set([category]) client.force_login(admin) resp = client.post(f'/admin-users/{target.pk}/delete/') assert resp.status_code == 302 diff --git a/tests/test_api_entries.py b/tests/test_api_entries.py new file mode 100644 index 0000000..6dd9326 --- /dev/null +++ b/tests/test_api_entries.py @@ -0,0 +1,92 @@ +"""Tests for the JSON entry-create API, focused on the project/category/lab_priority +multi-select backward-compatibility contract.""" +import json +from datetime import date, timedelta + +import pytest +from django.urls import reverse + +from apps.accounts.models import APIToken, User +from apps.entries.models import WorkItem +from apps.taxonomy.models import Category, LabPriority, Project + + +@pytest.fixture +def api_user(db): + return User.objects.create_user(username='apiuser', email='api@example.com', password='pass') + + +@pytest.fixture +def token(db, api_user): + return APIToken.rotate(api_user) + + +@pytest.fixture +def project(db): + return Project.objects.create(name='DUNE', slug='dune') + + +@pytest.fixture +def project2(db): + return Project.objects.create(name='NOvA', slug='nova') + + +@pytest.fixture +def category(db): + return Category.objects.create(name='Scientific', slug='scientific') + + +def _post(client, token, payload): + today = date.today() + base = { + 'title': 'API entry', + 'description': 'work done', + 'period_kind': 'week', + 'period_start': today.isoformat(), + 'period_end': (today + timedelta(days=6)).isoformat(), + } + base.update(payload) + return client.post( + reverse('api-entry-create'), + data=json.dumps(base), + content_type='application/json', + HTTP_AUTHORIZATION=f'Bearer {token.key}', + ) + + +class TestEntryCreateAPI: + def test_single_slug_still_works(self, client, token, project, category): + # Legacy clients send single 'project'/'category' slugs. + resp = _post(client, token, {'project': 'dune', 'category': 'scientific'}) + assert resp.status_code == 201, resp.content + data = resp.json() + entry = WorkItem.objects.get(pk=data['id']) + assert list(entry.projects.values_list('slug', flat=True)) == ['dune'] + assert list(entry.categories.values_list('slug', flat=True)) == ['scientific'] + # Response carries both plural lists and legacy singular keys. + assert data['projects'] == ['dune'] + assert data['project'] == 'dune' + assert data['categories'] == ['scientific'] + assert data['category'] == 'scientific' + + def test_list_input_creates_multiple(self, client, token, project, project2, category): + resp = _post(client, token, {'projects': ['dune', 'nova'], 'categories': ['scientific']}) + assert resp.status_code == 201, resp.content + data = resp.json() + entry = WorkItem.objects.get(pk=data['id']) + assert set(entry.projects.values_list('slug', flat=True)) == {'dune', 'nova'} + assert set(data['projects']) == {'dune', 'nova'} + + def test_missing_project_is_an_error(self, client, token, category): + resp = _post(client, token, {'categories': ['scientific']}) + assert resp.status_code == 400 + assert 'projects' in resp.json()['errors'] + + def test_lab_priorities_optional_list(self, client, token, project, category, db): + lp = LabPriority.objects.create(name='AI', slug='ai') + resp = _post(client, token, { + 'project': 'dune', 'category': 'scientific', 'lab_priorities': ['ai'], + }) + assert resp.status_code == 201, resp.content + entry = WorkItem.objects.get(pk=resp.json()['id']) + assert list(entry.lab_priorities.values_list('slug', flat=True)) == ['ai'] diff --git a/tests/test_audit.py b/tests/test_audit.py index b743a99..ea93857 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -13,6 +13,18 @@ from apps.taxonomy.models import Category, Project, Tag +def _new_entry(project=None, category=None, lab_priority=None, **kwargs): + """Create a WorkItem and set its project/category/lab_priority M2M relations.""" + item = WorkItem.objects.create(**kwargs) + if project is not None: + item.projects.set(project if isinstance(project, (list, tuple)) else [project]) + if category is not None: + item.categories.set(category if isinstance(category, (list, tuple)) else [category]) + if lab_priority is not None: + item.lab_priorities.set(lab_priority if isinstance(lab_priority, (list, tuple)) else [lab_priority]) + return item + + @pytest.fixture def admin_user(db): return User.objects.create_user( @@ -41,7 +53,7 @@ def category(db): def _make_entry(user, project, category): today = date.today() start = today - timedelta(days=today.weekday()) - return WorkItem.objects.create( + return _new_entry( author=user, title='Signal test entry', project=project, diff --git a/tests/test_entries.py b/tests/test_entries.py index b1deb5b..89a18f8 100644 --- a/tests/test_entries.py +++ b/tests/test_entries.py @@ -9,6 +9,18 @@ from apps.taxonomy.models import Category, Project, Tag +def _new_entry(project=None, category=None, lab_priority=None, **kwargs): + """Create a WorkItem and set its project/category/lab_priority M2M relations.""" + item = WorkItem.objects.create(**kwargs) + if project is not None: + item.projects.set(project if isinstance(project, (list, tuple)) else [project]) + if category is not None: + item.categories.set(category if isinstance(category, (list, tuple)) else [category]) + if lab_priority is not None: + item.lab_priorities.set(lab_priority if isinstance(lab_priority, (list, tuple)) else [lab_priority]) + return item + + @pytest.fixture def user(db): return User.objects.create_user(username='tester', email='tester@example.com', password='pass') @@ -27,7 +39,7 @@ def category(db): @pytest.fixture def entry(db, user, project, category): today = date.today() - return WorkItem.objects.create( + return _new_entry( author=user, title='My entry', project=project, @@ -65,7 +77,7 @@ def _make_entry(self, user, project, category, **kwargs): period_start=today, period_end=today, description='', ) defaults.update(kwargs) - return WorkItem.objects.create(**defaults) + return _new_entry(**defaults) def test_search_filters_by_title(self, db, client, user, project, category): self._make_entry(user, project, category, title='Quarterly budget review') @@ -135,8 +147,8 @@ def test_post_creates_entry(self, client, user, project, category): today = date.today() resp = client.post(reverse('entries:create'), { 'title': 'New entry', - 'project': project.pk, - 'category': category.pk, + 'projects': [project.pk], + 'categories': [category.pk], 'period_kind': 'week', 'period_start': (today - timedelta(days=today.weekday())).isoformat(), 'period_end': (today - timedelta(days=today.weekday()) + timedelta(days=6)).isoformat(), @@ -146,13 +158,47 @@ def test_post_creates_entry(self, client, user, project, category): assert resp.status_code == 302 assert WorkItem.objects.filter(title='New entry', author=user).exists() + def test_post_creates_entry_with_multiple_projects(self, client, user, project, category): + p2 = Project.objects.create(name='Second', slug='second') + c2 = Category.objects.create(name='SecondCat', slug='secondcat') + client.force_login(user) + today = date.today() + client.post(reverse('entries:create'), { + 'title': 'Multi entry', + 'projects': [project.pk, p2.pk], + 'categories': [category.pk, c2.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': '', + }) + entry = WorkItem.objects.get(title='Multi entry') + assert set(entry.projects.values_list('pk', flat=True)) == {project.pk, p2.pk} + assert set(entry.categories.values_list('pk', flat=True)) == {category.pk, c2.pk} + + def test_post_requires_at_least_one_project(self, client, user, project, category): + client.force_login(user) + today = date.today() + resp = client.post(reverse('entries:create'), { + 'title': 'No project', + 'categories': [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 == 200 # form re-rendered with validation errors + assert not WorkItem.objects.filter(title='No project').exists() + def test_sets_author_to_current_user(self, client, user, project, category): client.force_login(user) today = date.today() client.post(reverse('entries:create'), { 'title': 'Authored', - 'project': project.pk, - 'category': category.pk, + 'projects': [project.pk], + 'categories': [category.pk], 'period_kind': 'week', 'period_start': (today - timedelta(days=today.weekday())).isoformat(), 'period_end': (today - timedelta(days=today.weekday()) + timedelta(days=6)).isoformat(), @@ -168,8 +214,8 @@ def test_post_with_entry_type_sets_it(self, client, user, project, category): today = date.today() client.post(reverse('entries:create'), { 'title': 'Typed entry', - 'project': project.pk, - 'category': category.pk, + 'projects': [project.pk], + 'categories': [category.pk], 'entry_type': et.pk, 'period_kind': 'week', 'period_start': (today - timedelta(days=today.weekday())).isoformat(), @@ -184,8 +230,8 @@ def test_entry_type_is_optional(self, client, user, project, category): today = date.today() resp = client.post(reverse('entries:create'), { 'title': 'No type', - 'project': project.pk, - 'category': category.pk, + 'projects': [project.pk], + 'categories': [category.pk], 'period_kind': 'week', 'period_start': (today - timedelta(days=today.weekday())).isoformat(), 'period_end': (today - timedelta(days=today.weekday()) + timedelta(days=6)).isoformat(), @@ -201,8 +247,8 @@ def test_post_with_tags_increments_use_count(self, client, user, project, catego today = date.today() client.post(reverse('entries:create'), { 'title': 'Tagged', - 'project': project.pk, - 'category': category.pk, + 'projects': [project.pk], + 'categories': [category.pk], 'period_kind': 'week', 'period_start': (today - timedelta(days=today.weekday())).isoformat(), 'period_end': (today - timedelta(days=today.weekday()) + timedelta(days=6)).isoformat(), @@ -279,8 +325,8 @@ def test_can_edit_own_entry(self, client, user, entry): client.force_login(user) resp = client.post(reverse('entries:edit', kwargs={'pk': entry.pk}), { 'title': 'Updated title', - 'project': entry.project.pk, - 'category': entry.category.pk, + 'projects': list(entry.projects.values_list('pk', flat=True)), + 'categories': list(entry.categories.values_list('pk', flat=True)), 'period_kind': entry.period_kind, 'period_start': entry.period_start.isoformat(), 'period_end': entry.period_end.isoformat(), @@ -303,8 +349,8 @@ def test_cannot_edit_other_users_entry(self, db, client, entry): client.force_login(other) resp = client.post(reverse('entries:edit', kwargs={'pk': entry.pk}), { 'title': 'Hijacked', - 'project': entry.project.pk, - 'category': entry.category.pk, + 'projects': list(entry.projects.values_list('pk', flat=True)), + 'categories': list(entry.categories.values_list('pk', flat=True)), 'period_kind': entry.period_kind, 'period_start': entry.period_start.isoformat(), 'period_end': entry.period_end.isoformat(), @@ -322,7 +368,7 @@ class TestEntryDelete: def test_delete_decrements_tag_use_count(self, client, user, project, category): tag = Tag.objects.create(name='deletetag', use_count=1) today = date.today() - item = WorkItem.objects.create( + item = _new_entry( author=user, title='To delete', project=project, category=category, period_kind='week', period_start=today - timedelta(days=today.weekday()), @@ -486,7 +532,7 @@ def author(self, db): def managed_entry(self, db, author, project, category): today = date.today() start = today - timedelta(days=today.weekday()) - return WorkItem.objects.create( + return _new_entry( author=author, title='Manager target entry', project=project, @@ -588,7 +634,7 @@ def test_manage_shows_division_head_only_to_admin(self, client, admin, managed_e def test_manage_title_search_filter(self, client, admin, managed_entry, project, category, author): today = date.today() start = today - timedelta(days=today.weekday()) - other = WorkItem.objects.create( + other = _new_entry( author=author, title='Completely different', project=project, category=category, period_kind='week', period_start=start, period_end=start + timedelta(days=6), diff --git a/tests/test_reports.py b/tests/test_reports.py index 71362fb..efe0dd1 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -14,6 +14,18 @@ from apps.taxonomy.models import Category, EntryType, Project, WorkGroup +def _new_entry(project=None, category=None, lab_priority=None, **kwargs): + """Create a WorkItem and set its project/category/lab_priority M2M relations.""" + item = WorkItem.objects.create(**kwargs) + if project is not None: + item.projects.set(project if isinstance(project, (list, tuple)) else [project]) + if category is not None: + item.categories.set(category if isinstance(category, (list, tuple)) else [category]) + if lab_priority is not None: + item.lab_priorities.set(lab_priority if isinstance(lab_priority, (list, tuple)) else [lab_priority]) + return item + + @pytest.fixture def admin_user(db): return User.objects.create_user( @@ -51,7 +63,7 @@ def category(db): def entry(db, regular_user, project, category): today = date.today() start = today - timedelta(days=today.weekday()) - return WorkItem.objects.create( + return _new_entry( author=regular_user, title='Test entry', project=project, @@ -94,9 +106,30 @@ def test_empty_filters_returns_all(self, client, admin_user, entry): def test_project_filter(self, client, admin_user, entry, project, category, db): other = Project.objects.create(name='CMS', slug='cms') client.force_login(admin_user) - resp = client.post(reverse('reports:preview'), {'project': other.pk}) + resp = client.post(reverse('reports:preview'), {'projects': [other.pk]}) assert b'Test entry' not in resp.content + def test_projects_filter_matches_any(self, client, admin_user, entry, project, category, db): + p2 = Project.objects.create(name='CMS', slug='cms') + today = date.today() + start = today - timedelta(days=today.weekday()) + _new_entry( + project=p2, category=category, author=entry.author, title='Second entry', + period_kind='week', period_start=start, period_end=start + timedelta(days=6), + description='x', + ) + client.force_login(admin_user) + # Selecting both projects returns entries matching EITHER (OR semantics). + resp = client.post(reverse('reports:preview'), {'projects': [project.pk, p2.pk]}) + body = resp.content.decode() + assert 'Test entry' in body + assert 'Second entry' in body + # Selecting only the second project excludes the first entry. + resp = client.post(reverse('reports:preview'), {'projects': [p2.pk]}) + body = resp.content.decode() + assert 'Test entry' not in body + assert 'Second entry' in body + def test_author_email_filter(self, client, admin_user, entry): client.force_login(admin_user) resp = client.post(reverse('reports:preview'), {'author_email': 'nomatch@x.com'}) @@ -224,7 +257,7 @@ class TestGroupScopeFiltering: def _make_entry(self, author, project, category, group=None): today = date.today() start = today - timedelta(days=today.weekday()) - return WorkItem.objects.create( + return _new_entry( author=author, title=f'Entry by {author.email}', project=project,