Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion apps/audit/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions apps/core/templates/core/api.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,13 @@ <h2 class="text-base font-semibold text-blue-900">REST API</h2>
<pre class="bg-slate-50 border border-slate-200 rounded p-3 text-slate-700 overflow-x-auto leading-relaxed">{
"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,
Expand All @@ -79,8 +79,10 @@ <h2 class="text-base font-semibold text-blue-900">REST API</h2>
<pre class="bg-slate-50 border border-slate-200 rounded p-3 text-slate-700 overflow-x-auto leading-relaxed">{
"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",
Expand Down
2 changes: 1 addition & 1 deletion apps/core/templates/core/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ <h2 class="font-semibold text-slate-800">Recent Entries</h2>
<a href="{% url 'entries:detail' entry.pk %}"
class="text-sm font-medium text-slate-800 hover:text-scd-primary truncate block">{{ entry.title }}</a>
<p class="text-xs text-slate-400 mt-0.5">
{{ 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" }}
</p>
</div>
Expand Down
7 changes: 3 additions & 4 deletions apps/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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', {
Expand Down
14 changes: 11 additions & 3 deletions apps/entries/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Comment on lines 15 to +16
@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())
56 changes: 45 additions & 11 deletions apps/entries/api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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):
Expand All @@ -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(),
Expand Down
26 changes: 17 additions & 9 deletions apps/entries/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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)
Expand Down
85 changes: 85 additions & 0 deletions apps/entries/migrations/0009_multiselect_taxonomy.py
Original file line number Diff line number Diff line change
@@ -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",
),
]
Loading