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 %}
+
+
- {# ── Project / Category / Entry Type / Group / Lab Priority ───────────── #}
+ {# ── Projects / Categories / Lab Priorities / Entry Type / Group ──────── #}
-
- Project *
+
+ Projects *
-
- — select —
- {% for pk, label in form.fields.project.choices %}
- {% if pk %}
- {{ label }}
- {% endif %}
- {% endfor %}
-
- {% for error in form.project.errors %}
+ {{ form.projects }}
+ Hold Ctrl (⌘ on Mac) to select more than one.
+ {% for error in form.projects.errors %}
{{ error }}
{% endfor %}
-
- Category *
+
+ Categories *
-
- — select —
- {% for pk, label in form.fields.category.choices %}
- {% if pk %}
- {{ label }}
- {% endif %}
- {% endfor %}
-
- {% for error in form.category.errors %}
+ {{ form.categories }}
+ Hold Ctrl (⌘ on Mac) to select more than one.
+ {% for error in form.categories.errors %}
+ {{ error }}
+ {% endfor %}
+
+
+
+ Lab Priorities
+
+ {{ 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 %}
-
-
- Lab Priority
-
-
- — none —
- {% for pk, label in form.fields.lab_priority.choices %}
- {% if pk %}
- {{ label }}
- {% endif %}
- {% 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) #}
Project
+ {% 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 #}
-
- All my projects
{% for p in project_scope %}
- {{ p.name }}
+ {{ p.name }}
{% endfor %}
+
Select one or more (none = all).
{% endif %}
{% endwith %}
{% else %}
- {# Full dropdown for auditors/admins/division heads/group leaders #}
-
- All projects
- {% for pk, label in filter.filters.project.field.choices %}
- {% if pk %}
- {{ label }}
- {% endif %}
+ {% for pk, label in filter.filters.projects.field.choices %}
+ {{ label }}
{% endfor %}
+
Select one or more (none = all).
{% endif %}
+ {% endwith %}
- {# Category #}
+ {# Categories (multi-select; none selected = all) #}
Category
-
- All categories
- {% for pk, label in filter.filters.category.field.choices %}
- {% if pk %}
- {{ label }}
- {% endif %}
+ {% for pk, label in filter.filters.categories.field.choices %}
+ {{ label }}
{% endfor %}
+
Select one or more (none = all).
+ {% endwith %}
{# Entry Type #}
@@ -241,19 +241,19 @@ Filters
- {# Lab Priority #}
+ {# Lab Priorities (multi-select; none selected = all) #}
Lab Priority
-
- All lab priorities
- {% for pk, label in filter.filters.lab_priority.field.choices %}
- {% if pk %}
- {{ label }}
- {% endif %}
+ {% for pk, label in filter.filters.lab_priorities.field.choices %}
+ {{ label }}
{% endfor %}
+
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,