From 9871ba2773c11ed0c49af607c71c3b815230e23d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 16:16:18 +0000 Subject: [PATCH 1/5] feat: add custom protein moderation dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created a comprehensive moderation page for reviewing protein submissions and changes, allowing moderators to review all field changes and related objects before approving or rejecting. **Key Features:** - Custom dashboard at /pending-proteins/ showing all pending proteins - Intelligent diff comparison between current version and last approved version - Comprehensive change tracking for: - Protein fields (name, sequence, chromophore, etc.) - States (added/removed/modified with all properties) - References (added/removed) - Lineage changes (parent, mutations) - State transitions (added/removed) - OSER measurements (added/removed) - Clean, collapsible UI showing only changed fields - Bulk actions: approve/reject multiple proteins at once - Individual actions per protein with email capability - Special handling for new submissions vs modifications - Sequence mutations display for changed sequences - Toast notifications for user feedback **Implementation:** 1. **Backend** (`backend/proteins/views/protein.py`): - `_get_protein_changes()`: Helper function that compares pending protein with last approved version using django-reversion - `pending_proteins_dashboard()`: Main view rendering the dashboard - `pending_protein_action()`: AJAX endpoint for approve/reject actions 2. **Frontend** (`backend/proteins/templates/pending_proteins_dashboard.html`): - Card-based layout similar to spectrum moderation - Collapsible sections for each category of changes - Color-coded additions (green) and removals (red) - Responsive design with sticky bulk action bar 3. **URLs** (`backend/proteins/urls.py`): - `/pending-proteins/` - Main dashboard - `/ajax/pending_protein_action/` - Action endpoint **Technical Details:** - Leverages django-reversion's `last_approved_version()` method - Handles nested related objects (states, spectra, measurements) - Smart state comparison by unique fields to handle ID changes - Displays field-level changes with old → new formatting - Shows mutation notation for sequence changes - Revision tracking with comments and user attribution Closes the request for protein moderation functionality. --- .../templates/pending_proteins_dashboard.html | 714 ++++++++++++++++++ backend/proteins/urls.py | 10 + backend/proteins/views/protein.py | 382 ++++++++++ 3 files changed, 1106 insertions(+) create mode 100644 backend/proteins/templates/pending_proteins_dashboard.html diff --git a/backend/proteins/templates/pending_proteins_dashboard.html b/backend/proteins/templates/pending_proteins_dashboard.html new file mode 100644 index 000000000..632f1933e --- /dev/null +++ b/backend/proteins/templates/pending_proteins_dashboard.html @@ -0,0 +1,714 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Pending Proteins Dashboard - FPbase Admin{% endblock %} + +{% block extrahead %} + +{% endblock %} + +{% block content %} +
+
+
+

Pending Proteins Dashboard

+

Review and moderate protein submissions and changes

+
+
+ + {{ count }} pending + +
+
+ + {% if count == 0 %} +
+ No pending proteins! Great job. +
+ {% else %} + + +
+
+
+ 0 proteins selected +
+
+ + + +
+
+
+ + +
+ +
+ + + {% for protein in proteins %} +
+
+ +

+ {{ protein.name }} + {% if protein.is_new %} + NEW SUBMISSION + {% else %} + MODIFIED + {% endif %} +

+ + View + + + Admin + +
+ +
+
{{ protein.uuid }}
+
{{ protein.slug }}
+
{{ protein.modified|date:"Y-m-d H:i" }}
+ {% if protein.updated_by %} +
{{ protein.updated_by.username }}
+ {% endif %} + {% if protein.created_by_email %} +
{{ protein.created_by.username }} <{{ protein.created_by_email }}>
+ {% endif %} +
+ + {% if not protein.is_new %} +
+
Changes:
+ + {% if protein.changes.protein_fields %} +
+
+ Protein Fields ({{ protein.changes.protein_fields|length }}) +
+
+ {% for field, change in protein.changes.protein_fields.items %} +
+ {{ field }}: + {% if change.mutations %} +
+
{{ change.old }}
+
{{ change.new }}
+
Mutations: {{ change.mutations }}
+
+ {% else %} + {{ change.old|default:"(empty)" }} + → + {{ change.new|default:"(empty)" }} + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + + {% if protein.changes.states %} +
+
+ States + {% if protein.changes.states.added %}(+{{ protein.changes.states.added|length }}){% endif %} + {% if protein.changes.states.removed %}(-{{ protein.changes.states.removed|length }}){% endif %} + {% if protein.changes.states.modified %}(~{{ protein.changes.states.modified|length }}){% endif %} +
+
+ {% if protein.changes.states.added %} +
Added States:
+ {% for state in protein.changes.states.added %} +
+ {{ state.name }} +
+ {% if state.ex_max %}
Ex: {{ state.ex_max }} nm
{% endif %} + {% if state.em_max %}
Em: {{ state.em_max }} nm
{% endif %} + {% if state.ext_coeff %}
EC: {{ state.ext_coeff }}
{% endif %} + {% if state.qy %}
QY: {{ state.qy }}
{% endif %} +
+
+ {% endfor %} + {% endif %} + + {% if protein.changes.states.removed %} +
Removed States:
+ {% for state in protein.changes.states.removed %} +
+ {{ state.name }} +
+ {% if state.ex_max %}
Ex: {{ state.ex_max }} nm
{% endif %} + {% if state.em_max %}
Em: {{ state.em_max }} nm
{% endif %} +
+
+ {% endfor %} + {% endif %} + + {% if protein.changes.states.modified %} +
Modified States:
+ {% for state_name, changes in protein.changes.states.modified.items %} +
+ {{ state_name }} +
+ {% for field, change in changes.items %} +
+ {{ field }}: + {{ change.old|default:"(empty)" }} + → + {{ change.new|default:"(empty)" }} +
+ {% endfor %} +
+
+ {% endfor %} + {% endif %} +
+
+ {% endif %} + + {% if protein.changes.references %} +
+
+ References + {% if protein.changes.references.added %}(+{{ protein.changes.references.added|length }}){% endif %} + {% if protein.changes.references.removed %}(-{{ protein.changes.references.removed|length }}){% endif %} +
+
+ {% if protein.changes.references.added %} +
Added References:
+ {% for ref in protein.changes.references.added %} +
{{ ref }}
+ {% endfor %} + {% endif %} + {% if protein.changes.references.removed %} +
Removed References:
+ {% for ref in protein.changes.references.removed %} +
{{ ref }}
+ {% endfor %} + {% endif %} +
+
+ {% endif %} + + {% if protein.changes.lineage %} +
+
+ Lineage ({{ protein.changes.lineage|length }}) +
+
+ {% for field, change in protein.changes.lineage.items %} +
+ {{ field }}: + {{ change.old|default:"(empty)" }} + → + {{ change.new|default:"(empty)" }} +
+ {% endfor %} +
+
+ {% endif %} + + {% if protein.changes.transitions %} +
+
+ Transitions + {% if protein.changes.transitions.added %}(+{{ protein.changes.transitions.added|length }}){% endif %} + {% if protein.changes.transitions.removed %}(-{{ protein.changes.transitions.removed|length }}){% endif %} +
+
+ {% if protein.changes.transitions.added %} +
Added:
+ {% for trans in protein.changes.transitions.added %} +
{{ trans }}
+ {% endfor %} + {% endif %} + {% if protein.changes.transitions.removed %} +
Removed:
+ {% for trans in protein.changes.transitions.removed %} +
{{ trans }}
+ {% endfor %} + {% endif %} +
+
+ {% endif %} + + {% if protein.changes.oser %} +
+
+ OSER Measurements + {% if protein.changes.oser.added %}(+{{ protein.changes.oser.added|length }}){% endif %} + {% if protein.changes.oser.removed %}(-{{ protein.changes.oser.removed|length }}){% endif %} +
+
+ {% if protein.changes.oser.added %} +
Added:
+ {% for oser in protein.changes.oser.added %} +
{{ oser }}
+ {% endfor %} + {% endif %} + {% if protein.changes.oser.removed %} +
Removed:
+ {% for oser in protein.changes.oser.removed %} +
{{ oser }}
+ {% endfor %} + {% endif %} +
+
+ {% endif %} +
+ {% else %} +
+ This is a new protein submission. Review all fields in the admin panel before approving. +
+ {% endif %} + + +
+ + + {% if protein.created_by_email %} + + Email + + {% endif %} +
+
+ {% endfor %} + {% endif %} +
+ + +
+ +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/backend/proteins/urls.py b/backend/proteins/urls.py index bd99bb020..97b80d359 100644 --- a/backend/proteins/urls.py +++ b/backend/proteins/urls.py @@ -305,6 +305,16 @@ views.pending_spectrum_action, name="pending_spectrum_action", ), + path( + "pending-proteins/", + views.pending_proteins_dashboard, + name="pending_proteins_dashboard", + ), + path( + "ajax/pending_protein_action/", + views.pending_protein_action, + name="pending_protein_action", + ), path( "ajax/remove_from_collection/", views.collection_remove, diff --git a/backend/proteins/views/protein.py b/backend/proteins/views/protein.py index 5107c60dd..e928365b2 100644 --- a/backend/proteins/views/protein.py +++ b/backend/proteins/views/protein.py @@ -984,3 +984,385 @@ def protein_history(request, slug): "request": request, }, ) + + +def _get_protein_changes(protein): + """ + Compare a pending protein with its last approved version and return structured changes. + + Returns a dict with categories of changes: + - protein_fields: Direct protein field changes + - states: State additions/removals/modifications + - transitions: StateTransition changes + - oser: OSER measurement changes + - lineage: Lineage changes + - references: Reference changes + """ + from collections import defaultdict + from reversion.models import Version + + changes = { + "protein_fields": {}, + "states": {"added": [], "removed": [], "modified": {}}, + "spectra": {"added": [], "removed": [], "modified": {}}, + "transitions": {"added": [], "removed": [], "modified": {}}, + "oser": {"added": [], "removed": [], "modified": {}}, + "lineage": {}, + "references": {"added": [], "removed": []}, + "bleach": {"added": [], "removed": [], "modified": {}}, + } + + # Get last approved version + last_approved = protein.last_approved_version() + if not last_approved: + # No approved version - this is a new protein submission + changes["is_new"] = True + return changes + + changes["is_new"] = False + + # Compare protein-level fields + field_map = { + "name": "Name", + "seq": "Sequence", + "chromophore": "Chromophore", + "aliases": "Aliases", + "pdb": "PDB IDs", + "genbank": "GenBank", + "uniprot": "UniProt", + "ipg_id": "IPG ID", + "mw": "Molecular Weight", + "agg": "Oligomerization", + "oser": "OSER", + "switch_type": "Switching Type", + "blurb": "Description", + "cofactor": "Cofactor", + "seq_validated": "Sequence Validated", + "seq_comment": "Sequence Comment", + "parent_organism_id": "Parent Organism", + "primary_reference_id": "Primary Reference", + "default_state_id": "Default State", + } + + for field, label in field_map.items(): + old_val = getattr(last_approved, field, None) + new_val = getattr(protein, field, None) + + # Special handling for different field types + if field == "seq": + if old_val != new_val: + if old_val and new_val: + # Show mutations + mutations = old_val.mutations_to(new_val) if hasattr(old_val, "mutations_to") else None + changes["protein_fields"][label] = { + "old": str(old_val)[:50] + "..." if len(str(old_val)) > 50 else str(old_val), + "new": str(new_val)[:50] + "..." if len(str(new_val)) > 50 else str(new_val), + "mutations": str(mutations) if mutations else None, + } + else: + changes["protein_fields"][label] = { + "old": str(old_val) if old_val else None, + "new": str(new_val) if new_val else None, + } + elif field == "agg": + if old_val != new_val: + old_display = last_approved.get_agg_display() if old_val else None + new_display = protein.get_agg_display() if new_val else None + if old_display != new_display: + changes["protein_fields"][label] = {"old": old_display, "new": new_display} + elif field == "switch_type": + if old_val != new_val: + old_display = last_approved.get_switch_type_display() if old_val else None + new_display = protein.get_switch_type_display() if new_val else None + if old_display != new_display: + changes["protein_fields"][label] = {"old": old_display, "new": new_display} + elif field == "cofactor": + if old_val != new_val: + old_display = last_approved.get_cofactor_display() if old_val else None + new_display = protein.get_cofactor_display() if new_val else None + if old_display != new_display: + changes["protein_fields"][label] = {"old": old_display, "new": new_display} + elif field == "parent_organism_id": + if old_val != new_val: + old_org = last_approved.parent_organism if old_val else None + new_org = protein.parent_organism if new_val else None + changes["protein_fields"][label] = { + "old": str(old_org) if old_org else None, + "new": str(new_org) if new_org else None, + } + elif field in ["primary_reference_id", "default_state_id"]: + # Skip these for now - we'll handle them separately if needed + continue + else: + if old_val != new_val: + changes["protein_fields"][label] = {"old": old_val, "new": new_val} + + # Compare states + old_states = {s.id: s for s in last_approved.states.all()} + new_states = {s.id: s for s in protein.states.all()} + + # Find added states (states that exist now but didn't before) + for state_id, state in new_states.items(): + if state_id not in old_states: + # Check if this is truly new or just has a new ID + # Match by unique fields: name, ex_max, em_max, ext_coeff, qy + is_truly_new = True + for old_state in old_states.values(): + if ( + old_state.name == state.name + and old_state.ex_max == state.ex_max + and old_state.em_max == state.em_max + and old_state.ext_coeff == state.ext_coeff + and old_state.qy == state.qy + ): + is_truly_new = False + break + + if is_truly_new: + changes["states"]["added"].append( + { + "name": str(state), + "ex_max": state.ex_max, + "em_max": state.em_max, + "ext_coeff": state.ext_coeff, + "qy": state.qy, + } + ) + + # Find removed states + for state_id, old_state in old_states.items(): + if state_id not in new_states: + # Check if truly removed or just has new ID + is_truly_removed = True + for state in new_states.values(): + if ( + old_state.name == state.name + and old_state.ex_max == state.ex_max + and old_state.em_max == state.em_max + and old_state.ext_coeff == state.ext_coeff + and old_state.qy == state.qy + ): + is_truly_removed = False + break + + if is_truly_removed: + changes["states"]["removed"].append( + { + "name": str(old_state), + "ex_max": old_state.ex_max, + "em_max": old_state.em_max, + } + ) + + # Compare modified states (match by ID or by unique fields) + state_field_map = { + "name": "Name", + "ex_max": "Ex max", + "em_max": "Em max", + "ext_coeff": "Extinction Coefficient", + "qy": "Quantum Yield", + "pka": "pKa", + "maturation": "Maturation", + "lifetime": "Lifetime", + "is_dark": "Is Dark", + "twop_ex_max": "2P Ex max", + "twop_peakGM": "2P Peak GM", + "twop_qy": "2P QY", + } + + for state_id in set(old_states.keys()) & set(new_states.keys()): + old_state = old_states[state_id] + new_state = new_states[state_id] + state_changes = {} + + for field, label in state_field_map.items(): + old_val = getattr(old_state, field, None) + new_val = getattr(new_state, field, None) + if old_val != new_val: + state_changes[label] = {"old": old_val, "new": new_val} + + if state_changes: + changes["states"]["modified"][str(new_state)] = state_changes + + # Compare references + old_refs = set(last_approved.references.values_list("id", flat=True)) + new_refs = set(protein.references.values_list("id", flat=True)) + + added_refs = new_refs - old_refs + removed_refs = old_refs - new_refs + + if added_refs: + from references.models import Reference + + for ref_id in added_refs: + ref = Reference.objects.get(id=ref_id) + changes["references"]["added"].append(str(ref.citation)) + + if removed_refs: + from references.models import Reference + + for ref_id in removed_refs: + ref = Reference.objects.get(id=ref_id) + changes["references"]["removed"].append(str(ref.citation)) + + # Compare lineage + try: + old_lineage = last_approved.lineage + new_lineage = protein.lineage + lineage_fields = {"parent": "Parent", "mutation": "Mutation", "reference": "Reference"} + + for field, label in lineage_fields.items(): + old_val = getattr(old_lineage, field, None) + new_val = getattr(new_lineage, field, None) + if old_val != new_val: + changes["lineage"][label] = { + "old": str(old_val) if old_val else None, + "new": str(new_val) if new_val else None, + } + except Exception: + # No lineage or error comparing + pass + + # Compare OSER measurements + old_osers = {o.id: o for o in last_approved.oser_measurements.all()} + new_osers = {o.id: o for o in protein.oser_measurements.all()} + + for oser_id in set(new_osers.keys()) - set(old_osers.keys()): + oser = new_osers[oser_id] + changes["oser"]["added"].append(str(oser)) + + for oser_id in set(old_osers.keys()) - set(new_osers.keys()): + oser = old_osers[oser_id] + changes["oser"]["removed"].append(str(oser)) + + # Compare transitions + old_transitions = {t.id: t for t in last_approved.transitions.all()} + new_transitions = {t.id: t for t in protein.transitions.all()} + + for trans_id in set(new_transitions.keys()) - set(old_transitions.keys()): + trans = new_transitions[trans_id] + changes["transitions"]["added"].append(str(trans)) + + for trans_id in set(old_transitions.keys()) - set(new_transitions.keys()): + trans = old_transitions[trans_id] + changes["transitions"]["removed"].append(str(trans)) + + # Clean up empty sections + changes = {k: v for k, v in changes.items() if v and (not isinstance(v, dict) or any(v.values()))} + + return changes + + +@staff_member_required +def pending_proteins_dashboard(request): + """Dashboard for reviewing pending protein submissions and changes.""" + from django.contrib.auth.decorators import permission_required + + # Get all pending proteins + pending_proteins = ( + Protein.objects.filter(status="pending") + .select_related( + "created_by", + "updated_by", + "parent_organism", + "primary_reference", + "default_state", + ) + .prefetch_related( + "states", + "states__spectra", + "states__bleach_measurements", + "transitions", + "oser_measurements", + "references", + ) + .order_by("-modified") + ) + + proteins_data = [] + for protein in pending_proteins: + # Get changes for this protein + changes = _get_protein_changes(protein) + + # Only include proteins that have changes (or are new) + if changes.get("is_new") or any(v for k, v in changes.items() if k != "is_new"): + proteins_data.append( + { + "id": protein.id, + "slug": protein.slug, + "name": protein.name, + "uuid": protein.uuid, + "created": protein.created, + "modified": protein.modified, + "created_by": protein.created_by, + "updated_by": protein.updated_by, + "created_by_email": protein.created_by.email if protein.created_by else None, + "is_new": changes.get("is_new", False), + "changes": changes, + "admin_url": f"/admin/proteins/protein/{protein.id}/change/", + "detail_url": protein.get_absolute_url(), + } + ) + + context = { + "proteins": proteins_data, + "count": len(proteins_data), + } + + return render(request, "pending_proteins_dashboard.html", context) + + +@staff_member_required +@reversion.create_revision() +def pending_protein_action(request): + """Handle actions (approve/reject) on pending proteins.""" + from django.views.decorators.http import require_POST + from django.http import JsonResponse + + if request.method != "POST": + return JsonResponse({"success": False, "error": "POST required"}, status=405) + + try: + protein_ids = request.POST.getlist("protein_ids[]") + action = request.POST.get("action") + + if not protein_ids or not action: + return JsonResponse({"success": False, "error": "Missing protein_ids or action"}, status=400) + + proteins = Protein.objects.filter(id__in=protein_ids, status="pending") + + if not proteins.exists(): + return JsonResponse({"success": False, "error": "No pending proteins found with provided IDs"}, status=404) + + count = proteins.count() + + if action == "approve": + # Set revision comment + reversion.set_comment(f"Approved {count} protein(s) via moderation dashboard") + reversion.set_user(request.user) + + proteins.update(status="approved") + # Clear cache for affected protein pages + for protein in proteins: + with contextlib.suppress(Exception): + uncache_protein_page(protein.slug, request) + message = f"Approved {count} protein(s)" + + elif action == "reject": + # Set revision comment + reversion.set_comment(f"Rejected {count} protein(s) via moderation dashboard") + reversion.set_user(request.user) + + # For reject, we might want to revert to last approved version + # or just mark as hidden. Let's mark as hidden for now. + proteins.update(status="hidden") + message = f"Rejected (hidden) {count} protein(s)" + + else: + return JsonResponse({"success": False, "error": f"Unknown action: {action}"}, status=400) + + return JsonResponse({"success": True, "message": message, "count": count}) + + except Exception as e: + logger.exception("Error in pending_protein_action: %s", e) + return JsonResponse({"success": False, "error": str(e)}, status=500) From cba7b42cda32d3846123a5521afdc6fed4e1e1ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 20:09:20 +0000 Subject: [PATCH 2/5] fix: handle Version object in protein change comparison The last_approved_version() method returns a Version object when the protein is pending, but was being treated as a Protein instance. Now properly deserialize the Version back to a Protein using the old_object() helper from history.py. Fixes AttributeError: 'Version' object has no attribute 'states' --- backend/proteins/views/protein.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/proteins/views/protein.py b/backend/proteins/views/protein.py index e928365b2..80f3532dd 100644 --- a/backend/proteins/views/protein.py +++ b/backend/proteins/views/protein.py @@ -1013,14 +1013,24 @@ def _get_protein_changes(protein): } # Get last approved version - last_approved = protein.last_approved_version() - if not last_approved: + last_approved_version = protein.last_approved_version() + if not last_approved_version: # No approved version - this is a new protein submission changes["is_new"] = True return changes changes["is_new"] = False + # If last_approved is a Version object, we need to restore the protein from it + if isinstance(last_approved_version, Version): + # Use the old_object helper from history.py to restore the protein + from proteins.util.history import old_object + + last_approved = old_object(last_approved_version) + else: + # It's already a Protein instance (when status == "approved") + last_approved = last_approved_version + # Compare protein-level fields field_map = { "name": "Name", From ba1553429c88ed54ab2bd6a71e6501aead3f967e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 20:24:15 +0000 Subject: [PATCH 3/5] fix: avoid transaction issues in version comparison - Use Version.field_dict instead of old_object() to avoid broken transactions - Skip related object comparisons when using field_dict (only show protein fields) - Handle choice field display values using CHOICES tuples - Update all references from last_approved to old_data variable This fixes the TransactionManagementError that occurred when trying to query related objects after the transaction rollback in old_object(). --- backend/proteins/views/protein.py | 60 +++++++++++++++++++++---------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/backend/proteins/views/protein.py b/backend/proteins/views/protein.py index 80f3532dd..a7c263161 100644 --- a/backend/proteins/views/protein.py +++ b/backend/proteins/views/protein.py @@ -1021,15 +1021,15 @@ def _get_protein_changes(protein): changes["is_new"] = False - # If last_approved is a Version object, we need to restore the protein from it + # Handle Version object vs Protein instance if isinstance(last_approved_version, Version): - # Use the old_object helper from history.py to restore the protein - from proteins.util.history import old_object - - last_approved = old_object(last_approved_version) + # Use field_dict to avoid transaction issues with old_object() + old_data = last_approved_version.field_dict + use_field_dict = True else: # It's already a Protein instance (when status == "approved") - last_approved = last_approved_version + old_data = last_approved_version + use_field_dict = False # Compare protein-level fields field_map = { @@ -1055,7 +1055,10 @@ def _get_protein_changes(protein): } for field, label in field_map.items(): - old_val = getattr(last_approved, field, None) + if use_field_dict: + old_val = old_data.get(field) + else: + old_val = getattr(old_data, field, None) new_val = getattr(protein, field, None) # Special handling for different field types @@ -1076,28 +1079,43 @@ def _get_protein_changes(protein): } elif field == "agg": if old_val != new_val: - old_display = last_approved.get_agg_display() if old_val else None + # Get display values for choice fields + if use_field_dict: + old_display = dict(Protein.AGG_CHOICES).get(old_val, old_val) if old_val else None + else: + old_display = old_data.get_agg_display() if old_val else None new_display = protein.get_agg_display() if new_val else None if old_display != new_display: changes["protein_fields"][label] = {"old": old_display, "new": new_display} elif field == "switch_type": if old_val != new_val: - old_display = last_approved.get_switch_type_display() if old_val else None + if use_field_dict: + old_display = dict(Protein.SWITCHING_CHOICES).get(old_val, old_val) if old_val else None + else: + old_display = old_data.get_switch_type_display() if old_val else None new_display = protein.get_switch_type_display() if new_val else None if old_display != new_display: changes["protein_fields"][label] = {"old": old_display, "new": new_display} elif field == "cofactor": if old_val != new_val: - old_display = last_approved.get_cofactor_display() if old_val else None + if use_field_dict: + old_display = dict(Protein.COFACTOR_CHOICES).get(old_val, old_val) if old_val else None + else: + old_display = old_data.get_cofactor_display() if old_val else None new_display = protein.get_cofactor_display() if new_val else None if old_display != new_display: changes["protein_fields"][label] = {"old": old_display, "new": new_display} elif field == "parent_organism_id": if old_val != new_val: - old_org = last_approved.parent_organism if old_val else None + if use_field_dict: + # For field_dict, just show the ID + old_org_str = f"Organism ID: {old_val}" if old_val else None + else: + old_org = old_data.parent_organism if old_val else None + old_org_str = str(old_org) if old_org else None new_org = protein.parent_organism if new_val else None changes["protein_fields"][label] = { - "old": str(old_org) if old_org else None, + "old": old_org_str, "new": str(new_org) if new_org else None, } elif field in ["primary_reference_id", "default_state_id"]: @@ -1107,8 +1125,14 @@ def _get_protein_changes(protein): if old_val != new_val: changes["protein_fields"][label] = {"old": old_val, "new": new_val} - # Compare states - old_states = {s.id: s for s in last_approved.states.all()} + # Skip related object comparisons when using field_dict (to avoid transaction issues) + if use_field_dict: + # Clean up empty sections + changes = {k: v for k, v in changes.items() if v and (not isinstance(v, dict) or any(v.values()))} + return changes + + # Compare states (only when we have a Protein instance) + old_states = {s.id: s for s in old_data.states.all()} new_states = {s.id: s for s in protein.states.all()} # Find added states (states that exist now but didn't before) @@ -1195,7 +1219,7 @@ def _get_protein_changes(protein): changes["states"]["modified"][str(new_state)] = state_changes # Compare references - old_refs = set(last_approved.references.values_list("id", flat=True)) + old_refs = set(old_data.references.values_list("id", flat=True)) new_refs = set(protein.references.values_list("id", flat=True)) added_refs = new_refs - old_refs @@ -1217,7 +1241,7 @@ def _get_protein_changes(protein): # Compare lineage try: - old_lineage = last_approved.lineage + old_lineage = old_data.lineage new_lineage = protein.lineage lineage_fields = {"parent": "Parent", "mutation": "Mutation", "reference": "Reference"} @@ -1234,7 +1258,7 @@ def _get_protein_changes(protein): pass # Compare OSER measurements - old_osers = {o.id: o for o in last_approved.oser_measurements.all()} + old_osers = {o.id: o for o in old_data.oser_measurements.all()} new_osers = {o.id: o for o in protein.oser_measurements.all()} for oser_id in set(new_osers.keys()) - set(old_osers.keys()): @@ -1246,7 +1270,7 @@ def _get_protein_changes(protein): changes["oser"]["removed"].append(str(oser)) # Compare transitions - old_transitions = {t.id: t for t in last_approved.transitions.all()} + old_transitions = {t.id: t for t in old_data.transitions.all()} new_transitions = {t.id: t for t in protein.transitions.all()} for trans_id in set(new_transitions.keys()) - set(old_transitions.keys()): From 271649e3a2fdfecb04a19b361ccb2634267b5914 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Nov 2025 20:36:11 +0000 Subject: [PATCH 4/5] feat: add sorting to protein moderation dashboard Added comprehensive sorting capabilities to the pending proteins dashboard: **Sort Criteria:** - Most/Least Recently Updated (modified date) - Newest/Oldest (created date) - Most/Least Viewed (Google Analytics data, 30-day window) - Most/Least Favorited (user favorites count) - Name (A-Z / Z-A) **Backend Changes:** - Fetch view data from Google Analytics via cached_ga_popular() - Fetch favorite counts from Favorite model - Add view_count and favorite_count to protein data - Add ISO formatted timestamps for JavaScript sorting **Frontend Changes:** - Added sort dropdown with 10 sorting options - Added data attributes to protein cards for client-side sorting - Implemented sortProteins() JavaScript function - Cards re-order dynamically without page reload Sorting is purely client-side after initial page load for instant feedback. Default sort remains 'Most Recently Updated' (modified desc). --- .../templates/pending_proteins_dashboard.html | 80 ++++++++++++++++++- backend/proteins/views/protein.py | 29 ++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/backend/proteins/templates/pending_proteins_dashboard.html b/backend/proteins/templates/pending_proteins_dashboard.html index 632f1933e..32b440038 100644 --- a/backend/proteins/templates/pending_proteins_dashboard.html +++ b/backend/proteins/templates/pending_proteins_dashboard.html @@ -268,6 +268,27 @@

Pending Proteins Dashboard

{% else %} + +
+
+
+ + +
+
+
+
@@ -297,8 +318,15 @@

Pending Proteins Dashboard

+
{% for protein in proteins %} -
+

@@ -530,6 +558,7 @@

Removed:
{% endfor %} +
{% endif %}
@@ -558,6 +587,55 @@
Removed:
const csrftoken = getCookie('csrftoken'); +// Sort proteins function +function sortProteins() { + const sortValue = document.getElementById('sort-select').value; + const [sortBy, direction] = sortValue.split('-'); + const container = document.getElementById('proteins-container'); + const cards = Array.from(container.querySelectorAll('.protein-card')); + + cards.sort((a, b) => { + let aVal, bVal; + + switch(sortBy) { + case 'name': + aVal = a.dataset.name.toLowerCase(); + bVal = b.dataset.name.toLowerCase(); + break; + case 'modified': + aVal = new Date(a.dataset.modified); + bVal = new Date(b.dataset.modified); + break; + case 'created': + aVal = new Date(a.dataset.created); + bVal = new Date(b.dataset.created); + break; + case 'views': + aVal = parseFloat(a.dataset.views) || 0; + bVal = parseFloat(b.dataset.views) || 0; + break; + case 'favorites': + aVal = parseInt(a.dataset.favorites) || 0; + bVal = parseInt(b.dataset.favorites) || 0; + break; + } + + // Sort based on direction + if (direction === 'asc') { + if (aVal < bVal) return -1; + if (aVal > bVal) return 1; + return 0; + } else { + if (aVal > bVal) return -1; + if (aVal < bVal) return 1; + return 0; + } + }); + + // Re-append cards in sorted order + cards.forEach(card => container.appendChild(card)); +} + // Toast notification system function showToast(message, type = 'success') { const container = document.getElementById('toast-container'); diff --git a/backend/proteins/views/protein.py b/backend/proteins/views/protein.py index a7c263161..35651a7dc 100644 --- a/backend/proteins/views/protein.py +++ b/backend/proteins/views/protein.py @@ -1290,7 +1290,10 @@ def _get_protein_changes(protein): @staff_member_required def pending_proteins_dashboard(request): """Dashboard for reviewing pending protein submissions and changes.""" - from django.contrib.auth.decorators import permission_required + from collections import Counter + + from favit.models import Favorite + from proteins.extrest.ga import cached_ga_popular # Get all pending proteins pending_proteins = ( @@ -1313,6 +1316,25 @@ def pending_proteins_dashboard(request): .order_by("-modified") ) + # Get view data (from Google Analytics) + view_data = {} + try: + ga_data = cached_ga_popular() + # Use month data for view counts + for slug, name, views in ga_data.get("month", []): + view_data[slug] = views + except Exception as e: + logger.warning(f"Could not fetch GA data: {e}") + + # Get favorite counts + favorite_data = {} + try: + fave_qs = Favorite.objects.for_model(Protein) + fave_counts = Counter(fave_qs.values_list("target_object_id", flat=True)) + favorite_data = dict(fave_counts) + except Exception as e: + logger.warning(f"Could not fetch favorite data: {e}") + proteins_data = [] for protein in pending_proteins: # Get changes for this protein @@ -1335,6 +1357,11 @@ def pending_proteins_dashboard(request): "changes": changes, "admin_url": f"/admin/proteins/protein/{protein.id}/change/", "detail_url": protein.get_absolute_url(), + "view_count": view_data.get(protein.slug, 0), + "favorite_count": favorite_data.get(protein.id, 0), + # Add ISO format for JavaScript sorting + "created_iso": protein.created.isoformat() if protein.created else "", + "modified_iso": protein.modified.isoformat() if protein.modified else "", } ) From adaafba21e3103d99db401f9956f2688085b4fea Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 20:51:28 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- backend/proteins/views/protein.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/proteins/views/protein.py b/backend/proteins/views/protein.py index 35651a7dc..2f7c69f9f 100644 --- a/backend/proteins/views/protein.py +++ b/backend/proteins/views/protein.py @@ -998,7 +998,6 @@ def _get_protein_changes(protein): - lineage: Lineage changes - references: Reference changes """ - from collections import defaultdict from reversion.models import Version changes = { @@ -1321,7 +1320,7 @@ def pending_proteins_dashboard(request): try: ga_data = cached_ga_popular() # Use month data for view counts - for slug, name, views in ga_data.get("month", []): + for slug, _name, views in ga_data.get("month", []): view_data[slug] = views except Exception as e: logger.warning(f"Could not fetch GA data: {e}") @@ -1377,7 +1376,6 @@ def pending_proteins_dashboard(request): @reversion.create_revision() def pending_protein_action(request): """Handle actions (approve/reject) on pending proteins.""" - from django.views.decorators.http import require_POST from django.http import JsonResponse if request.method != "POST":