From 0e0ed01ceef2ec7ca39ce41e493e287a5b136511 Mon Sep 17 00:00:00 2001
From: pacnpal <183241239+pacnpal@users.noreply.github.com>
Date: Fri, 7 Feb 2025 13:13:49 -0500
Subject: [PATCH] Add comment and reply functionality with preview and
notification templates
---
history_tracking/comparison.py | 237 +++++++++++
history_tracking/htmx_views.py | 123 ++++++
history_tracking/managers.py | 321 +++++++++++++++
history_tracking/mixins.py | 63 ++-
history_tracking/models.py | 115 +++++-
history_tracking/notifications.py | 229 +++++++++++
history_tracking/state_machine.py | 194 +++++++++
.../history_tracking/approval_status.html | 174 ++++++++
.../partials/approval_notifications.html | 51 +++
.../partials/approval_status.html | 102 +++++
.../partials/comment_preview.html | 6 +
.../partials/comment_replies.html | 27 ++
.../partials/comments_list.html | 35 ++
.../history_tracking/partials/reply_form.html | 33 ++
.../history_tracking/version_comparison.html | 170 ++++++++
history_tracking/urls.py | 60 ++-
history_tracking/views.py | 344 ++++++----------
.../implementation_checklist.md | 151 +------
static/css/approval-panel.css | 332 +++++++++++++++
static/css/diff-viewer.css | 195 +++++++++
static/css/inline-comment-panel.css | 229 +++++++++++
static/css/version-comparison.css | 353 ++++++++++++++++
static/css/version-control.css | 109 +++++
static/js/collaboration-system.js | 203 ++++++++++
static/js/components/approval-panel.js | 234 +++++++++++
static/js/components/diff-viewer.js | 274 +++++++++++++
static/js/components/inline-comment-panel.js | 285 +++++++++++++
static/js/components/version-comparison.js | 314 ++++++++++++++
static/js/components/virtual-scroller.js | 190 +++++++++
static/js/version-control.js | 383 +++++++++++++++++-
30 files changed, 5153 insertions(+), 383 deletions(-)
create mode 100644 history_tracking/comparison.py
create mode 100644 history_tracking/htmx_views.py
create mode 100644 history_tracking/notifications.py
create mode 100644 history_tracking/state_machine.py
create mode 100644 history_tracking/templates/history_tracking/approval_status.html
create mode 100644 history_tracking/templates/history_tracking/partials/approval_notifications.html
create mode 100644 history_tracking/templates/history_tracking/partials/approval_status.html
create mode 100644 history_tracking/templates/history_tracking/partials/comment_preview.html
create mode 100644 history_tracking/templates/history_tracking/partials/comment_replies.html
create mode 100644 history_tracking/templates/history_tracking/partials/comments_list.html
create mode 100644 history_tracking/templates/history_tracking/partials/reply_form.html
create mode 100644 history_tracking/templates/history_tracking/version_comparison.html
create mode 100644 static/css/approval-panel.css
create mode 100644 static/css/diff-viewer.css
create mode 100644 static/css/inline-comment-panel.css
create mode 100644 static/css/version-comparison.css
create mode 100644 static/js/collaboration-system.js
create mode 100644 static/js/components/approval-panel.js
create mode 100644 static/js/components/diff-viewer.js
create mode 100644 static/js/components/inline-comment-panel.js
create mode 100644 static/js/components/version-comparison.js
create mode 100644 static/js/components/virtual-scroller.js
diff --git a/history_tracking/comparison.py b/history_tracking/comparison.py
new file mode 100644
index 00000000..c0f09cf1
--- /dev/null
+++ b/history_tracking/comparison.py
@@ -0,0 +1,237 @@
+import asyncio
+import json
+from typing import List, Dict, Any, Optional
+from django.core.cache import cache
+from django.db import models
+from django.utils import timezone
+from concurrent.futures import ThreadPoolExecutor
+from .models import VersionTag, ChangeSet
+
+class StructuredDiff:
+ def __init__(self, version1: str, version2: str):
+ self.version1 = version1
+ self.version2 = version2
+ self.changes: List[Dict[str, Any]] = []
+ self.impact_score = 0.0
+ self.computation_time = 0.0
+ self.timestamp = timezone.now()
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ 'version1': self.version1,
+ 'version2': self.version2,
+ 'changes': self.changes,
+ 'impact_score': self.impact_score,
+ 'computation_time': self.computation_time,
+ 'timestamp': self.timestamp.isoformat()
+ }
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> 'StructuredDiff':
+ diff = cls(data['version1'], data['version2'])
+ diff.changes = data['changes']
+ diff.impact_score = data['impact_score']
+ diff.computation_time = data['computation_time']
+ diff.timestamp = timezone.datetime.fromisoformat(data['timestamp'])
+ return diff
+
+class ComparisonEngine:
+ """Handles version comparison operations with background processing and caching"""
+
+ def __init__(self, chunk_size: int = 10485760): # 10MB default chunk size
+ self.chunk_size = chunk_size
+ self.executor = ThreadPoolExecutor(max_workers=4)
+ self.cache_ttl = 300 # 5 minutes cache TTL
+
+ async def compare_versions(self, versions: List[str]) -> List[StructuredDiff]:
+ """
+ Compare multiple versions, processing in background and using cache
+ Args:
+ versions: List of version identifiers to compare
+ Returns:
+ List of StructuredDiff objects with comparison results
+ """
+ if len(versions) < 2:
+ raise ValueError("At least two versions required for comparison")
+
+ results: List[StructuredDiff] = []
+ cache_misses = []
+
+ # Check cache first
+ for i in range(len(versions) - 1):
+ for j in range(i + 1, len(versions)):
+ cache_key = self._get_cache_key(versions[i], versions[j])
+ cached_result = cache.get(cache_key)
+
+ if cached_result:
+ results.append(StructuredDiff.from_dict(json.loads(cached_result)))
+ else:
+ cache_misses.append((versions[i], versions[j]))
+
+ # Process cache misses in background
+ if cache_misses:
+ comparison_tasks = [
+ self._compare_version_pair(v1, v2)
+ for v1, v2 in cache_misses
+ ]
+ new_results = await asyncio.gather(*comparison_tasks)
+ results.extend(new_results)
+
+ return sorted(
+ results,
+ key=lambda x: (x.version1, x.version2)
+ )
+
+ def calculate_impact_score(self, diffs: List[StructuredDiff]) -> float:
+ """
+ Calculate impact score for a set of diffs
+ Args:
+ diffs: List of StructuredDiff objects
+ Returns:
+ Float impact score (0-1)
+ """
+ if not diffs:
+ return 0.0
+
+ total_score = 0.0
+ weights = {
+ 'file_count': 0.3,
+ 'change_size': 0.3,
+ 'structural_impact': 0.4
+ }
+
+ for diff in diffs:
+ # File count impact
+ file_count = len(set(c['file'] for c in diff.changes))
+ file_score = min(file_count / 100, 1.0) # Normalize to max 100 files
+
+ # Change size impact
+ total_changes = sum(
+ len(c.get('additions', [])) + len(c.get('deletions', []))
+ for c in diff.changes
+ )
+ size_score = min(total_changes / 1000, 1.0) # Normalize to max 1000 lines
+
+ # Structural impact (e.g., function/class changes)
+ structural_changes = sum(
+ 1 for c in diff.changes
+ if c.get('type') in ['function', 'class', 'schema']
+ )
+ structural_score = min(structural_changes / 10, 1.0) # Normalize to max 10 structural changes
+
+ # Weighted average
+ diff.impact_score = (
+ weights['file_count'] * file_score +
+ weights['change_size'] * size_score +
+ weights['structural_impact'] * structural_score
+ )
+ total_score += diff.impact_score
+
+ return total_score / len(diffs)
+
+ async def _compare_version_pair(self, version1: str, version2: str) -> StructuredDiff:
+ """Compare two versions in background"""
+ start_time = timezone.now()
+
+ # Create diff structure
+ diff = StructuredDiff(version1, version2)
+
+ try:
+ # Get version data
+ v1_tag = await self._get_version_tag(version1)
+ v2_tag = await self._get_version_tag(version2)
+
+ if not v1_tag or not v2_tag:
+ raise ValueError("Version tag not found")
+
+ # Process in chunks if needed
+ changes = await self._process_version_changes(v1_tag, v2_tag)
+ diff.changes = changes
+
+ # Calculate impact score
+ diff.impact_score = self.calculate_impact_score([diff])
+
+ # Store in cache
+ cache_key = self._get_cache_key(version1, version2)
+ cache.set(
+ cache_key,
+ json.dumps(diff.to_dict()),
+ self.cache_ttl
+ )
+
+ except Exception as e:
+ diff.changes = [{'error': str(e)}]
+ diff.impact_score = 0.0
+
+ diff.computation_time = (timezone.now() - start_time).total_seconds()
+ return diff
+
+ async def _get_version_tag(self, version: str) -> Optional[VersionTag]:
+ """Get version tag by identifier"""
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(
+ self.executor,
+ lambda: VersionTag.objects.filter(name=version).first()
+ )
+
+ async def _process_version_changes(
+ self,
+ v1_tag: VersionTag,
+ v2_tag: VersionTag
+ ) -> List[Dict[str, Any]]:
+ """Process changes between versions in chunks"""
+ changes = []
+
+ # Get changesets between versions
+ changesets = await self._get_changesets_between(v1_tag, v2_tag)
+
+ for changeset in changesets:
+ # Process each change in chunks if needed
+ change_data = await self._process_changeset(changeset)
+ changes.extend(change_data)
+
+ return changes
+
+ async def _get_changesets_between(
+ self,
+ v1_tag: VersionTag,
+ v2_tag: VersionTag
+ ) -> List[ChangeSet]:
+ """Get all changesets between two versions"""
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(
+ self.executor,
+ lambda: list(ChangeSet.objects.filter(
+ branch=v2_tag.branch,
+ created_at__gt=v1_tag.created_at,
+ created_at__lte=v2_tag.created_at,
+ status='applied'
+ ).order_by('created_at'))
+ )
+
+ async def _process_changeset(self, changeset: ChangeSet) -> List[Dict[str, Any]]:
+ """Process individual changeset for comparison"""
+ loop = asyncio.get_event_loop()
+
+ def process():
+ changes = []
+ instance = changeset.historical_instance
+ if instance:
+ # Get changes from historical record
+ diff = instance.diff_against_previous
+ if diff:
+ for field, values in diff.items():
+ change = {
+ 'type': 'field',
+ 'file': f"{instance._meta.model_name}.{field}",
+ 'old_value': values['old'],
+ 'new_value': values['new']
+ }
+ changes.append(change)
+ return changes
+
+ return await loop.run_in_executor(self.executor, process)
+
+ def _get_cache_key(self, version1: str, version2: str) -> str:
+ """Generate cache key for version comparison"""
+ return f"version_diff:{version1}:{version2}"
\ No newline at end of file
diff --git a/history_tracking/htmx_views.py b/history_tracking/htmx_views.py
new file mode 100644
index 00000000..43e14cf2
--- /dev/null
+++ b/history_tracking/htmx_views.py
@@ -0,0 +1,123 @@
+from django.shortcuts import render, get_object_or_404
+from django.contrib.auth.decorators import login_required
+from django.views.decorators.http import require_http_methods
+from django.http import HttpRequest, HttpResponse, Http404
+from django.template.loader import render_to_string
+from django.core.exceptions import PermissionDenied
+
+from .models import ChangeSet, CommentThread, Comment
+from .notifications import NotificationDispatcher
+from .state_machine import ApprovalStateMachine
+
+@login_required
+def get_comments(request: HttpRequest) -> HttpResponse:
+ """HTMX endpoint to get comments for a specific anchor"""
+ anchor = request.GET.get('anchor')
+ if not anchor:
+ raise Http404("Anchor parameter is required")
+
+ thread = CommentThread.objects.filter(anchor__id=anchor).first()
+ comments = thread.comments.all() if thread else []
+
+ return render(request, 'history_tracking/partials/comments_list.html', {
+ 'comments': comments,
+ 'anchor': anchor
+ })
+
+@login_required
+@require_http_methods(["POST"])
+def preview_comment(request: HttpRequest) -> HttpResponse:
+ """HTMX endpoint for live comment preview"""
+ content = request.POST.get('content', '')
+ return render(request, 'history_tracking/partials/comment_preview.html', {
+ 'content': content
+ })
+
+@login_required
+@require_http_methods(["POST"])
+def add_comment(request: HttpRequest) -> HttpResponse:
+ """HTMX endpoint to add a comment"""
+ anchor = request.POST.get('anchor')
+ content = request.POST.get('content')
+ parent_id = request.POST.get('parent_id')
+
+ if not content:
+ return HttpResponse("Comment content is required", status=400)
+
+ thread, created = CommentThread.objects.get_or_create(
+ anchor={'id': anchor},
+ defaults={'created_by': request.user}
+ )
+
+ comment = thread.comments.create(
+ author=request.user,
+ content=content,
+ parent_id=parent_id if parent_id else None
+ )
+
+ comment.extract_mentions()
+
+ # Send notifications
+ dispatcher = NotificationDispatcher()
+ dispatcher.notify_new_comment(comment, thread)
+
+ # Return updated comments list
+ return render(request, 'history_tracking/partials/comments_list.html', {
+ 'comments': thread.comments.all(),
+ 'anchor': anchor
+ })
+
+@login_required
+@require_http_methods(["POST"])
+def approve_changes(request: HttpRequest, changeset_id: int) -> HttpResponse:
+ """HTMX endpoint for approving/rejecting changes"""
+ changeset = get_object_or_404(ChangeSet, pk=changeset_id)
+ state_machine = ApprovalStateMachine(changeset)
+
+ if not state_machine.can_user_approve(request.user):
+ raise PermissionDenied("You don't have permission to approve these changes")
+
+ decision = request.POST.get('decision', 'approve')
+ comment = request.POST.get('comment', '')
+ stage_id = request.POST.get('stage_id')
+
+ success = state_machine.submit_approval(
+ user=request.user,
+ decision=decision,
+ comment=comment,
+ stage_id=stage_id
+ )
+
+ if not success:
+ return HttpResponse("Failed to submit approval", status=400)
+
+ # Return updated approval status
+ return render(request, 'history_tracking/partials/approval_status.html', {
+ 'changeset': changeset,
+ 'current_stage': state_machine.get_current_stage(),
+ 'can_approve': state_machine.can_user_approve(request.user),
+ 'pending_approvers': state_machine.get_pending_approvers()
+ })
+
+@login_required
+def approval_notifications(request: HttpRequest, changeset_id: int) -> HttpResponse:
+ """HTMX endpoint for live approval notifications"""
+ changeset = get_object_or_404(ChangeSet, pk=changeset_id)
+ return render(request, 'history_tracking/partials/approval_notifications.html', {
+ 'notifications': changeset.get_recent_notifications()
+ })
+
+@login_required
+def get_replies(request: HttpRequest, comment_id: int) -> HttpResponse:
+ """HTMX endpoint to get comment replies"""
+ comment = get_object_or_404(Comment, pk=comment_id)
+ return render(request, 'history_tracking/partials/comment_replies.html', {
+ 'replies': comment.replies.all()
+ })
+
+@login_required
+def reply_form(request: HttpRequest) -> HttpResponse:
+ """HTMX endpoint to get the reply form template"""
+ return render(request, 'history_tracking/partials/reply_form.html', {
+ 'parent_id': request.GET.get('parent_id')
+ })
\ No newline at end of file
diff --git a/history_tracking/managers.py b/history_tracking/managers.py
index c0cb67b6..5f3b5fc0 100644
--- a/history_tracking/managers.py
+++ b/history_tracking/managers.py
@@ -5,6 +5,8 @@ from django.utils import timezone
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import AbstractUser
+from collections import Counter
+import json
from .models import VersionBranch, VersionTag, ChangeSet
UserModel = TypeVar('UserModel', bound=AbstractUser)
@@ -73,6 +75,142 @@ class BranchManager:
queryset = queryset.filter(is_active=True)
return list(queryset)
+ @transaction.atomic
+ def acquire_lock(
+ self,
+ branch: VersionBranch,
+ user: UserModel,
+ duration: int = 48,
+ reason: str = ""
+ ) -> bool:
+ """
+ Acquire a lock on a branch
+ Args:
+ branch: The branch to lock
+ user: User acquiring the lock
+ duration: Lock duration in hours (default 48)
+ reason: Reason for locking
+ Returns:
+ bool: True if lock acquired, False if already locked
+ """
+ # Check if branch is already locked
+ if branch.lock_status:
+ expires = timezone.datetime.fromisoformat(branch.lock_status['expires'])
+ if timezone.now() < expires:
+ return False
+
+ # Set lock
+ expiry = timezone.now() + timezone.timedelta(hours=duration)
+ branch.lock_status = {
+ 'user': user.id,
+ 'expires': expiry.isoformat(),
+ 'reason': reason
+ }
+
+ # Record in history
+ branch.lock_history.append({
+ 'user': user.id,
+ 'action': 'lock',
+ 'timestamp': timezone.now().isoformat(),
+ 'reason': reason,
+ 'expires': expiry.isoformat()
+ })
+
+ branch.save()
+ return True
+
+ @transaction.atomic
+ def release_lock(
+ self,
+ branch: VersionBranch,
+ user: UserModel,
+ force: bool = False
+ ) -> bool:
+ """
+ Release a lock on a branch
+ Args:
+ branch: The branch to unlock
+ user: User releasing the lock
+ force: Whether to force unlock (requires permissions)
+ Returns:
+ bool: True if lock released, False if not locked or unauthorized
+ """
+ if not branch.lock_status:
+ return False
+
+ locked_by = branch.lock_status.get('user')
+ if not locked_by:
+ return False
+
+ # Check authorization
+ if not force and locked_by != user.id:
+ if not user.has_perm('history_tracking.force_unlock_branch'):
+ return False
+
+ # Record in history
+ branch.lock_history.append({
+ 'user': user.id,
+ 'action': 'unlock',
+ 'timestamp': timezone.now().isoformat(),
+ 'forced': force
+ })
+
+ # Clear lock
+ branch.lock_status = {}
+ branch.save()
+ return True
+
+ def check_lock(self, branch: VersionBranch) -> Dict[str, Any]:
+ """
+ Check the lock status of a branch
+ Args:
+ branch: The branch to check
+ Returns:
+ dict: Lock status information
+ """
+ if not branch.lock_status:
+ return {'locked': False}
+
+ expires = timezone.datetime.fromisoformat(branch.lock_status['expires'])
+ if timezone.now() >= expires:
+ # Lock has expired
+ branch.lock_status = {}
+ branch.save()
+ return {'locked': False}
+
+ return {
+ 'locked': True,
+ 'user': User.objects.get(id=branch.lock_status['user']),
+ 'expires': expires,
+ 'reason': branch.lock_status.get('reason', '')
+ }
+
+ def get_lock_history(
+ self,
+ branch: VersionBranch,
+ limit: Optional[int] = None
+ ) -> List[Dict[str, Any]]:
+ """
+ Get the lock history for a branch
+ Args:
+ branch: The branch to get history for
+ limit: Optional limit on number of entries
+ Returns:
+ list: Lock history entries
+ """
+ history = branch.lock_history
+ if limit:
+ history = history[-limit:]
+
+ # Enhance history with user objects
+ for entry in history:
+ try:
+ entry['user_obj'] = User.objects.get(id=entry['user'])
+ except User.DoesNotExist:
+ entry['user_obj'] = None
+
+ return history
+
class ChangeTracker:
"""Tracks and manages changes across the system"""
@@ -114,6 +252,189 @@ class ChangeTracker:
"""Get all changes in a branch ordered by creation time"""
return list(ChangeSet.objects.filter(branch=branch).order_by('created_at'))
+ def compute_enhanced_diff(
+ self,
+ version1: Any,
+ version2: Any,
+ syntax_detect: bool = True
+ ) -> Dict[str, Any]:
+ """
+ Return structured diff with syntax metadata
+ Args:
+ version1: First version to compare
+ version2: Second version to compare
+ syntax_detect: Whether to detect syntax types
+ Returns:
+ Dict containing structured diff with metadata
+ """
+ if not hasattr(version1, 'history') or not hasattr(version2, 'history'):
+ raise ValueError("Both versions must be history-tracked models")
+
+ # Get historical records
+ v1_history = version1.history.first()
+ v2_history = version2.history.first()
+
+ if not (v1_history and v2_history):
+ raise ValueError("No historical records found")
+
+ changes = {}
+
+ # Compare fields and detect syntax
+ for field in v2_history._meta.fields:
+ field_name = field.name
+ if field_name in [
+ 'history_id', 'history_date', 'history_type',
+ 'history_user_id', 'history_change_reason'
+ ]:
+ continue
+
+ old_value = getattr(v1_history, field_name)
+ new_value = getattr(v2_history, field_name)
+
+ if old_value != new_value:
+ field_type = field.get_internal_type()
+ syntax_type = self._detect_syntax(field_type, old_value) if syntax_detect else 'text'
+
+ changes[field_name] = {
+ 'old': str(old_value),
+ 'new': str(new_value),
+ 'type': field_type,
+ 'syntax': syntax_type,
+ 'line_numbers': self._compute_line_numbers(old_value, new_value),
+ 'metadata': {
+ 'comment_anchor_id': f"{v2_history.history_id}_{field_name}",
+ 'field_type': field_type,
+ 'content_type': v2_history._meta.model_name
+ }
+ }
+
+ # Calculate impact metrics
+ impact_metrics = self._calculate_impact_metrics(changes)
+
+ return {
+ 'changes': changes,
+ 'metadata': {
+ 'version1_id': v1_history.history_id,
+ 'version2_id': v2_history.history_id,
+ 'timestamp': timezone.now().isoformat(),
+ 'impact_score': impact_metrics['impact_score'],
+ 'stats': impact_metrics['stats'],
+ 'performance': {
+ 'syntax_detection': syntax_detect,
+ 'computed_at': timezone.now().isoformat()
+ }
+ }
+ }
+
+ def _detect_syntax(self, field_type: str, value: Any) -> str:
+ """
+ Detect syntax type for field content
+ Args:
+ field_type: Django field type
+ value: Field value
+ Returns:
+ Detected syntax type
+ """
+ if field_type in ['TextField', 'CharField']:
+ # Try to detect if it's code
+ if isinstance(value, str):
+ if value.startswith('def ') or value.startswith('class '):
+ return 'python'
+ if value.startswith('{') or value.startswith('['):
+ try:
+ json.loads(value)
+ return 'json'
+ except:
+ pass
+ if value.startswith(' Dict[str, List[int]]:
+ """
+ Compute line numbers for diff navigation
+ Args:
+ old_value: Previous value
+ new_value: New value
+ Returns:
+ Dict with old and new line numbers
+ """
+ def count_lines(value):
+ if not isinstance(value, str):
+ value = str(value)
+ return value.count('\n') + 1
+
+ old_lines = count_lines(old_value)
+ new_lines = count_lines(new_value)
+
+ return {
+ 'old': list(range(1, old_lines + 1)),
+ 'new': list(range(1, new_lines + 1))
+ }
+
+ def _calculate_impact_metrics(self, changes: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Calculate impact metrics for changes
+ Args:
+ changes: Dict of changes
+ Returns:
+ Dict with impact metrics
+ """
+ total_lines_changed = sum(
+ len(c['line_numbers']['old']) + len(c['line_numbers']['new'])
+ for c in changes.values()
+ )
+
+ field_types = Counter(c['type'] for c in changes.values())
+ syntax_types = Counter(c['syntax'] for c in changes.values())
+
+ # Calculate impact score (0-1)
+ impact_weights = {
+ 'lines_changed': 0.4,
+ 'fields_changed': 0.3,
+ 'complexity': 0.3
+ }
+
+ # Normalize metrics
+ normalized_lines = min(1.0, total_lines_changed / 1000) # Cap at 1000 lines
+ normalized_fields = min(1.0, len(changes) / 20) # Cap at 20 fields
+
+ # Complexity based on field and syntax types
+ complexity_score = (
+ len(field_types) / 10 + # Variety of field types
+ len(syntax_types) / 5 + # Variety of syntax types
+ (field_types.get('JSONField', 0) * 0.2) + # Weight complex fields higher
+ (syntax_types.get('python', 0) * 0.2) # Weight code changes higher
+ ) / 2 # Normalize to 0-1
+
+ impact_score = (
+ impact_weights['lines_changed'] * normalized_lines +
+ impact_weights['fields_changed'] * normalized_fields +
+ impact_weights['complexity'] * complexity_score
+ )
+
+ return {
+ 'impact_score': impact_score,
+ 'stats': {
+ 'total_lines_changed': total_lines_changed,
+ 'fields_changed': len(changes),
+ 'field_types': dict(field_types),
+ 'syntax_types': dict(syntax_types)
+ }
+ }
+
class MergeStrategy:
"""Handles merge operations and conflict resolution"""
diff --git a/history_tracking/mixins.py b/history_tracking/mixins.py
index 1ddf4347..90384606 100644
--- a/history_tracking/mixins.py
+++ b/history_tracking/mixins.py
@@ -1,9 +1,11 @@
# history_tracking/mixins.py
from django.db import models
from django.conf import settings
+from django.contrib.contenttypes.fields import GenericRelation
class HistoricalChangeMixin(models.Model):
"""Mixin for historical models to track changes"""
+ comments = GenericRelation('CommentThread', related_query_name='historical_record')
id = models.BigIntegerField(db_index=True, auto_created=True, blank=True)
history_date = models.DateTimeField()
history_id = models.AutoField(primary_key=True)
@@ -33,6 +35,7 @@ class HistoricalChangeMixin(models.Model):
@property
def diff_against_previous(self):
+ """Get enhanced diff with syntax highlighting and metadata"""
prev_record = self.prev_record
if not prev_record:
return {}
@@ -54,11 +57,69 @@ class HistoricalChangeMixin(models.Model):
old_value = getattr(prev_record, field)
new_value = getattr(self, field)
if old_value != new_value:
- changes[field] = {"old": str(old_value), "new": str(new_value)}
+ field_type = self._meta.get_field(field).get_internal_type()
+ syntax_type = self._get_syntax_type(field_type)
+ changes[field] = {
+ "old": str(old_value),
+ "new": str(new_value),
+ "syntax_type": syntax_type,
+ "metadata": {
+ "field_type": field_type,
+ "comment_anchor_id": f"{self.history_id}_{field}",
+ "line_numbers": self._compute_line_numbers(old_value, new_value)
+ }
+ }
except AttributeError:
continue
return changes
+ def _get_syntax_type(self, field_type):
+ """Map Django field types to syntax highlighting types"""
+ syntax_map = {
+ 'TextField': 'text',
+ 'JSONField': 'json',
+ 'FileField': 'path',
+ 'ImageField': 'path',
+ 'URLField': 'url',
+ 'EmailField': 'email',
+ 'CodeField': 'python' # Custom field type for code
+ }
+ return syntax_map.get(field_type, 'text')
+
+ def _compute_line_numbers(self, old_value, new_value):
+ """Compute line numbers for diff navigation"""
+ old_lines = str(old_value).count('\n') + 1
+ new_lines = str(new_value).count('\n') + 1
+ return {
+ "old": list(range(1, old_lines + 1)),
+ "new": list(range(1, new_lines + 1))
+ }
+
+ def get_structured_diff(self, other_version=None):
+ """Get structured diff between two versions with enhanced metadata"""
+ compare_to = other_version or self.prev_record
+ if not compare_to:
+ return None
+
+ diff_data = self.diff_against_previous
+ return {
+ "changes": diff_data,
+ "metadata": {
+ "timestamp": self.history_date.isoformat(),
+ "user": self.history_user_display,
+ "change_type": self.history_type,
+ "reason": self.history_change_reason,
+ "performance": {
+ "computation_time": None # To be filled by frontend
+ }
+ },
+ "navigation": {
+ "next_id": None, # To be filled by frontend
+ "prev_id": None, # To be filled by frontend
+ "current_position": None # To be filled by frontend
+ }
+ }
+
@property
def history_user_display(self):
"""Get a display name for the history user"""
diff --git a/history_tracking/models.py b/history_tracking/models.py
index 52e2ae6c..f31d4aa3 100644
--- a/history_tracking/models.py
+++ b/history_tracking/models.py
@@ -1,6 +1,6 @@
from django.db import models
from django.contrib.contenttypes.models import ContentType
-from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.auth import get_user_model
from simple_history.models import HistoricalRecords
from .mixins import HistoricalChangeMixin
@@ -60,6 +60,14 @@ class VersionBranch(models.Model):
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
+ lock_status = models.JSONField(
+ default=dict,
+ help_text="Current lock status: {user: ID, expires: datetime, reason: str}"
+ )
+ lock_history = models.JSONField(
+ default=list,
+ help_text="History of lock operations: [{user: ID, action: lock/unlock, timestamp: datetime, reason: str}]"
+ )
class Meta:
ordering = ['-created_at']
@@ -91,6 +99,10 @@ class VersionTag(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
metadata = models.JSONField(default=dict, blank=True)
+ comparison_metadata = models.JSONField(
+ default=dict,
+ help_text="Stores diff statistics and comparison results"
+ )
class Meta:
ordering = ['-created_at']
@@ -104,6 +116,74 @@ class VersionTag(models.Model):
def __str__(self) -> str:
return f"{self.name} ({self.branch.name})"
+class CommentThread(models.Model):
+ """Represents a thread of comments on a historical record"""
+ content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
+ object_id = models.PositiveIntegerField()
+ content_object = GenericForeignKey('content_type', 'object_id')
+ created_at = models.DateTimeField(auto_now_add=True)
+ created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='created_threads')
+ anchor = models.JSONField(
+ default=dict,
+ help_text="Anchoring information: {line_start: int, line_end: int, file_path: str}"
+ )
+ is_resolved = models.BooleanField(default=False)
+ resolved_by = models.ForeignKey(
+ User,
+ on_delete=models.SET_NULL,
+ null=True,
+ related_name='resolved_threads'
+ )
+ resolved_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ ordering = ['-created_at']
+ indexes = [
+ models.Index(fields=['content_type', 'object_id']),
+ models.Index(fields=['created_at']),
+ models.Index(fields=['is_resolved']),
+ ]
+
+ def __str__(self) -> str:
+ return f"Comment Thread {self.pk} on {self.content_type}"
+
+class Comment(models.Model):
+ """Individual comment within a thread"""
+ thread = models.ForeignKey(CommentThread, on_delete=models.CASCADE, related_name='comments')
+ author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
+ content = models.TextField()
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+ mentioned_users = models.ManyToManyField(
+ User,
+ related_name='mentioned_in_comments',
+ blank=True
+ )
+ parent_comment = models.ForeignKey(
+ 'self',
+ on_delete=models.CASCADE,
+ null=True,
+ blank=True,
+ related_name='replies'
+ )
+
+ class Meta:
+ ordering = ['created_at']
+
+ def __str__(self) -> str:
+ return f"Comment {self.pk} by {self.author}"
+
+ def extract_mentions(self) -> None:
+ """Extract @mentions from comment content and update mentioned_users"""
+ # Simple @username extraction - could be enhanced with regex
+ mentioned = [
+ word[1:] for word in self.content.split()
+ if word.startswith('@') and len(word) > 1
+ ]
+ if mentioned:
+ users = User.objects.filter(username__in=mentioned)
+ self.mentioned_users.set(users)
+
class ChangeSet(models.Model):
"""Groups related changes together for atomic version control operations"""
branch = models.ForeignKey(VersionBranch, on_delete=models.CASCADE, related_name='changesets')
@@ -115,12 +195,41 @@ class ChangeSet(models.Model):
status = models.CharField(
max_length=20,
choices=[
- ('pending', 'Pending'),
+ ('draft', 'Draft'),
+ ('pending_approval', 'Pending Approval'),
+ ('approved', 'Approved'),
+ ('rejected', 'Rejected'),
('applied', 'Applied'),
('failed', 'Failed'),
('reverted', 'Reverted')
],
- default='pending'
+ default='draft'
+ )
+ approval_state = models.JSONField(
+ default=list,
+ help_text="List of approval stages and their status"
+ )
+ approval_history = models.JSONField(
+ default=list,
+ help_text="History of approval actions and decisions"
+ )
+ required_approvers = models.ManyToManyField(
+ User,
+ related_name='pending_approvals',
+ blank=True
+ )
+ approval_policy = models.CharField(
+ max_length=20,
+ choices=[
+ ('sequential', 'Sequential'),
+ ('parallel', 'Parallel')
+ ],
+ default='sequential'
+ )
+ approval_deadline = models.DateTimeField(
+ null=True,
+ blank=True,
+ help_text="Optional deadline for approvals"
)
# Instead of directly relating to HistoricalRecord, use GenericForeignKey
diff --git a/history_tracking/notifications.py b/history_tracking/notifications.py
new file mode 100644
index 00000000..aec71f85
--- /dev/null
+++ b/history_tracking/notifications.py
@@ -0,0 +1,229 @@
+from django.core.mail import send_mail
+from django.conf import settings
+from django.template.loader import render_to_string
+from django.utils import timezone
+from django.contrib.auth import get_user_model
+import requests
+import json
+from datetime import timedelta
+from celery import shared_task
+
+User = get_user_model()
+
+class NotificationDispatcher:
+ """Handles comment notifications and escalations"""
+
+ def __init__(self):
+ self.email_enabled = hasattr(settings, 'EMAIL_HOST')
+ self.slack_enabled = hasattr(settings, 'SLACK_WEBHOOK_URL')
+ self.sms_enabled = hasattr(settings, 'SMS_API_KEY')
+
+ def notify_new_comment(self, comment, thread):
+ """Handle notification for a new comment"""
+ # Queue immediate notifications
+ self.send_in_app_notification.delay(
+ user_ids=self._get_thread_participants(thread),
+ title="New Comment",
+ message=f"New comment on {thread.content_object}",
+ link=self._get_thread_url(thread)
+ )
+
+ # Queue email notifications
+ self.send_email_notification.delay(
+ user_ids=self._get_thread_participants(thread),
+ subject=f"New comment on {thread.content_object}",
+ template="notifications/new_comment.html",
+ context={
+ 'comment': comment,
+ 'thread': thread,
+ 'url': self._get_thread_url(thread)
+ }
+ )
+
+ # Schedule Slack escalation if needed
+ if self.slack_enabled:
+ self.schedule_slack_escalation.apply_async(
+ args=[comment.id],
+ countdown=24 * 3600 # 24 hours
+ )
+
+ def notify_mention(self, comment, mentioned_users):
+ """Handle notification for @mentions"""
+ user_ids = [user.id for user in mentioned_users]
+
+ # Queue immediate notifications
+ self.send_in_app_notification.delay(
+ user_ids=user_ids,
+ title="Mentioned in Comment",
+ message=f"{comment.author} mentioned you in a comment",
+ link=self._get_comment_url(comment)
+ )
+
+ # Queue email notifications
+ self.send_email_notification.delay(
+ user_ids=user_ids,
+ subject="You were mentioned in a comment",
+ template="notifications/mention.html",
+ context={
+ 'comment': comment,
+ 'url': self._get_comment_url(comment)
+ }
+ )
+
+ # Queue mobile push notifications
+ self.send_push_notification.delay(
+ user_ids=user_ids,
+ title="New Mention",
+ message=f"{comment.author} mentioned you: {comment.content[:100]}..."
+ )
+
+ # Schedule SMS escalation if needed
+ if self.sms_enabled:
+ self.schedule_sms_escalation.apply_async(
+ args=[comment.id, user_ids],
+ countdown=12 * 3600 # 12 hours
+ )
+
+ def notify_resolution(self, thread, resolver):
+ """Handle notification for thread resolution"""
+ self.send_in_app_notification.delay(
+ user_ids=self._get_thread_participants(thread),
+ title="Thread Resolved",
+ message=f"Thread resolved by {resolver}",
+ link=self._get_thread_url(thread)
+ )
+
+ @shared_task
+ def send_in_app_notification(user_ids, title, message, link):
+ """Send in-app notification to users"""
+ from .models import InAppNotification
+ for user_id in user_ids:
+ InAppNotification.objects.create(
+ user_id=user_id,
+ title=title,
+ message=message,
+ link=link
+ )
+
+ @shared_task
+ def send_email_notification(user_ids, subject, template, context):
+ """Send email notification to users"""
+ if not settings.EMAIL_HOST:
+ return
+
+ users = User.objects.filter(id__in=user_ids)
+ for user in users:
+ if not user.email:
+ continue
+
+ html_content = render_to_string(template, {
+ 'user': user,
+ **context
+ })
+
+ send_mail(
+ subject=subject,
+ message='',
+ html_message=html_content,
+ from_email=settings.DEFAULT_FROM_EMAIL,
+ recipient_list=[user.email]
+ )
+
+ @shared_task
+ def send_push_notification(user_ids, title, message):
+ """Send mobile push notification"""
+ from .models import PushToken
+
+ tokens = PushToken.objects.filter(
+ user_id__in=user_ids,
+ active=True
+ )
+
+ if not tokens:
+ return
+
+ # Implementation depends on push notification service
+ # Example using Firebase:
+ try:
+ requests.post(
+ settings.FIREBASE_FCM_URL,
+ headers={
+ 'Authorization': f'key={settings.FIREBASE_SERVER_KEY}',
+ 'Content-Type': 'application/json'
+ },
+ json={
+ 'registration_ids': [t.token for t in tokens],
+ 'notification': {
+ 'title': title,
+ 'body': message
+ }
+ }
+ )
+ except Exception as e:
+ print(f"Push notification failed: {e}")
+
+ @shared_task
+ def schedule_slack_escalation(comment_id):
+ """Send Slack DM escalation for unread comments"""
+ from .models import Comment
+
+ try:
+ comment = Comment.objects.get(id=comment_id)
+ if not comment.read_by.exists():
+ # Send Slack message
+ requests.post(
+ settings.SLACK_WEBHOOK_URL,
+ json={
+ 'text': (
+ f"Unread comment needs attention:\n"
+ f"{comment.content}\n"
+ f"View: {self._get_comment_url(comment)}"
+ )
+ }
+ )
+ except Exception as e:
+ print(f"Slack escalation failed: {e}")
+
+ @shared_task
+ def schedule_sms_escalation(comment_id, user_ids):
+ """Send SMS escalation for unread mentions"""
+ from .models import Comment
+
+ try:
+ comment = Comment.objects.get(id=comment_id)
+ users = User.objects.filter(id__in=user_ids)
+
+ for user in users:
+ if not user.phone_number:
+ continue
+
+ if not comment.read_by.filter(id=user.id).exists():
+ # Send SMS using Twilio or similar service
+ requests.post(
+ settings.SMS_API_URL,
+ headers={'Authorization': f'Bearer {settings.SMS_API_KEY}'},
+ json={
+ 'to': user.phone_number,
+ 'message': (
+ f"You were mentioned in a comment that needs attention. "
+ f"View: {self._get_comment_url(comment)}"
+ )
+ }
+ )
+ except Exception as e:
+ print(f"SMS escalation failed: {e}")
+
+ def _get_thread_participants(self, thread):
+ """Get IDs of all participants in a thread"""
+ return list(set(
+ [thread.created_by_id] +
+ list(thread.comments.values_list('author_id', flat=True))
+ ))
+
+ def _get_thread_url(self, thread):
+ """Generate URL for thread"""
+ return f"/version-control/comments/thread/{thread.id}/"
+
+ def _get_comment_url(self, comment):
+ """Generate URL for specific comment"""
+ return f"{self._get_thread_url(comment.thread)}#comment-{comment.id}"
\ No newline at end of file
diff --git a/history_tracking/state_machine.py b/history_tracking/state_machine.py
new file mode 100644
index 00000000..5aa2ef0b
--- /dev/null
+++ b/history_tracking/state_machine.py
@@ -0,0 +1,194 @@
+from typing import List, Dict, Any, Optional
+from django.contrib.auth import get_user_model
+from django.utils import timezone
+from .models import ChangeSet
+
+User = get_user_model()
+
+class ApprovalStage:
+ def __init__(self, stage_id: int, name: str, required_roles: List[str]):
+ self.id = stage_id
+ self.name = name
+ self.required_roles = required_roles
+ self.approvers: List[Dict[str, Any]] = []
+ self.status = 'pending' # pending, approved, rejected
+ self.completed_at = None
+
+ def add_approver(self, user: User, decision: str, comment: str = "") -> None:
+ """Add an approver's decision to this stage"""
+ self.approvers.append({
+ 'user_id': user.id,
+ 'username': user.username,
+ 'decision': decision,
+ 'comment': comment,
+ 'timestamp': timezone.now().isoformat()
+ })
+
+ def is_approved(self, policy: str = 'unanimous') -> bool:
+ """Check if stage is approved based on policy"""
+ if not self.approvers:
+ return False
+
+ approve_count = sum(1 for a in self.approvers if a['decision'] == 'approve')
+ if policy == 'unanimous':
+ return approve_count == len(self.required_roles)
+ else: # majority
+ return approve_count > len(self.required_roles) / 2
+
+class ApprovalStateMachine:
+ """Manages the state transitions for change approval workflow"""
+
+ def __init__(self, changeset: ChangeSet):
+ self.changeset = changeset
+ self.stages: List[ApprovalStage] = []
+ self.current_stage_index = 0
+ self.policy = changeset.approval_policy
+ self._load_state()
+
+ def _load_state(self) -> None:
+ """Load the current state from changeset approval_state"""
+ if not self.changeset.approval_state:
+ return
+
+ for stage_data in self.changeset.approval_state:
+ stage = ApprovalStage(
+ stage_data['id'],
+ stage_data['name'],
+ stage_data['required_roles']
+ )
+ stage.approvers = stage_data.get('approvers', [])
+ stage.status = stage_data.get('status', 'pending')
+ stage.completed_at = stage_data.get('completed_at')
+ self.stages.append(stage)
+
+ # Find current stage
+ self.current_stage_index = next(
+ (i for i, s in enumerate(self.stages) if s.status == 'pending'),
+ len(self.stages) - 1
+ )
+
+ def initialize_workflow(self, stages_config: List[Dict[str, Any]]) -> None:
+ """Set up initial approval workflow stages"""
+ self.stages = [
+ ApprovalStage(
+ i,
+ stage['name'],
+ stage['required_roles']
+ ) for i, stage in enumerate(stages_config)
+ ]
+ self._save_state()
+
+ def submit_approval(
+ self,
+ user: User,
+ decision: str,
+ comment: str = "",
+ stage_id: Optional[int] = None
+ ) -> bool:
+ """
+ Submit an approval decision
+ Args:
+ user: The user submitting approval
+ decision: 'approve' or 'reject'
+ comment: Optional comment
+ stage_id: Optional specific stage ID (for parallel approval)
+ Returns:
+ bool: True if submission was accepted
+ """
+ if self.changeset.status != 'pending_approval':
+ return False
+
+ if self.policy == 'sequential':
+ stage = self.stages[self.current_stage_index]
+ else: # parallel
+ if stage_id is None:
+ return False
+ stage = next((s for s in self.stages if s.id == stage_id), None)
+ if not stage:
+ return False
+
+ # Check if user has required role
+ user_roles = set(user.groups.values_list('name', flat=True))
+ if not any(role in user_roles for role in stage.required_roles):
+ return False
+
+ # Add decision
+ stage.add_approver(user, decision, comment)
+
+ # Update stage status
+ if stage.is_approved(self.policy):
+ stage.status = 'approved'
+ stage.completed_at = timezone.now().isoformat()
+
+ if self.policy == 'sequential':
+ self._advance_stage()
+ elif decision == 'reject':
+ stage.status = 'rejected'
+ stage.completed_at = timezone.now().isoformat()
+ self.changeset.status = 'rejected'
+ self.changeset.save()
+
+ self._save_state()
+ return True
+
+ def _advance_stage(self) -> None:
+ """Move to next stage if available"""
+ if self.current_stage_index < len(self.stages) - 1:
+ self.current_stage_index += 1
+ else:
+ # All stages approved
+ self.changeset.status = 'approved'
+ self.changeset.save()
+
+ def _save_state(self) -> None:
+ """Save current state to changeset"""
+ self.changeset.approval_state = [
+ {
+ 'id': stage.id,
+ 'name': stage.name,
+ 'required_roles': stage.required_roles,
+ 'approvers': stage.approvers,
+ 'status': stage.status,
+ 'completed_at': stage.completed_at
+ } for stage in self.stages
+ ]
+ self.changeset.save()
+
+ def get_current_stage(self) -> Optional[ApprovalStage]:
+ """Get the current active stage"""
+ if not self.stages:
+ return None
+ return self.stages[self.current_stage_index]
+
+ def get_stage_by_id(self, stage_id: int) -> Optional[ApprovalStage]:
+ """Get a specific stage by ID"""
+ return next((s for s in self.stages if s.id == stage_id), None)
+
+ def get_pending_approvers(self) -> List[str]:
+ """Get list of roles that still need to approve the current stage"""
+ current_stage = self.get_current_stage()
+ if not current_stage:
+ return []
+
+ approved_by = {a['user_id'] for a in current_stage.approvers}
+ return [
+ role for role in current_stage.required_roles
+ if not any(
+ user.id in approved_by
+ for user in User.objects.filter(groups__name=role)
+ )
+ ]
+
+ def can_user_approve(self, user: User) -> bool:
+ """Check if user can approve the current stage"""
+ current_stage = self.get_current_stage()
+ if not current_stage:
+ return False
+
+ # Check if user already approved
+ if any(a['user_id'] == user.id for a in current_stage.approvers):
+ return False
+
+ # Check if user has required role
+ user_roles = set(user.groups.values_list('name', flat=True))
+ return any(role in user_roles for role in current_stage.required_roles)
\ No newline at end of file
diff --git a/history_tracking/templates/history_tracking/approval_status.html b/history_tracking/templates/history_tracking/approval_status.html
new file mode 100644
index 00000000..77f6de2e
--- /dev/null
+++ b/history_tracking/templates/history_tracking/approval_status.html
@@ -0,0 +1,174 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block content %}
+
+
+
+ {% if changeset.description %}
+
+ {{ changeset.description }}
+
+ {% endif %}
+
+ {% if current_stage %}
+
+
Current Stage: {{ current_stage.name }}
+
+
+
Required Approvers:
+
+ {% for role in current_stage.required_roles %}
+ {{ role }}
+ {% endfor %}
+
+
+
+
+
Current Approvers:
+ {% if current_stage.approvers %}
+
+ {% else %}
+
No approvals yet
+ {% endif %}
+
+
+ {% if can_approve %}
+
+ {% endif %}
+
+ {% endif %}
+
+
+
Approval History
+ {% if changeset.approval_history %}
+
+ {% for entry in changeset.approval_history %}
+
+
+
+ {{ entry.user }}
+ {{ entry.action|title }}
+ {% if entry.comment %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+ {% else %}
+
No approval history yet
+ {% endif %}
+
+
+ {% if pending_approvers %}
+
+
Waiting for Approval From:
+
+ {% for role in pending_approvers %}
+ {{ role }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
+{% if messages %}
+
+ {% for message in messages %}
+
+ {{ message }}
+
+ {% endfor %}
+
+{% endif %}
+{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/history_tracking/templates/history_tracking/partials/approval_notifications.html b/history_tracking/templates/history_tracking/partials/approval_notifications.html
new file mode 100644
index 00000000..9440aa6d
--- /dev/null
+++ b/history_tracking/templates/history_tracking/partials/approval_notifications.html
@@ -0,0 +1,51 @@
+{% if notifications %}
+
+ {% for notification in notifications %}
+
+
+
+ {% if notification.type == 'approval' %}
+ {{ notification.user }}
+ {{ notification.action }} the changes
+ {% if notification.stage %}
+ in stage {{ notification.stage }}
+ {% endif %}
+ {% elif notification.type == 'comment' %}
+
+ commented on the changes
+ {% elif notification.type == 'stage_change' %}
+ Moved to stage {{ notification.stage }}
+ {% endif %}
+
+ {% if notification.comment %}
+
+ {% endif %}
+
+ {% if notification.actions %}
+
+ {% for action in notification.actions %}
+
+ {{ action.label }}
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% endfor %}
+
+{% else %}
+
+ No recent notifications
+
+{% endif %}
\ No newline at end of file
diff --git a/history_tracking/templates/history_tracking/partials/approval_status.html b/history_tracking/templates/history_tracking/partials/approval_status.html
new file mode 100644
index 00000000..f970c5c0
--- /dev/null
+++ b/history_tracking/templates/history_tracking/partials/approval_status.html
@@ -0,0 +1,102 @@
+{% if current_stage %}
+
+
+
Current Stage: {{ current_stage.name }}
+
+
+
Required Approvers:
+
+ {% for role in current_stage.required_roles %}
+
+ {{ role }}
+
+ {% endfor %}
+
+
+
+
+
Current Approvers:
+ {% if current_stage.approvers %}
+
+ {% else %}
+
No approvals yet
+ {% endif %}
+
+
+ {% if can_approve %}
+
+ {% endif %}
+
+ {% if messages %}
+
+ {% for message in messages %}
+
+ {{ message }}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+{% endif %}
\ No newline at end of file
diff --git a/history_tracking/templates/history_tracking/partials/comment_preview.html b/history_tracking/templates/history_tracking/partials/comment_preview.html
new file mode 100644
index 00000000..a2b04c66
--- /dev/null
+++ b/history_tracking/templates/history_tracking/partials/comment_preview.html
@@ -0,0 +1,6 @@
+{% if content %}
+
+{% endif %}
diff --git a/history_tracking/templates/history_tracking/partials/comment_replies.html b/history_tracking/templates/history_tracking/partials/comment_replies.html
new file mode 100644
index 00000000..4507458a
--- /dev/null
+++ b/history_tracking/templates/history_tracking/partials/comment_replies.html
@@ -0,0 +1,27 @@
+{% if replies %}
+
+ {% for reply in replies %}
+
+ {% endfor %}
+
+{% endif %}
\ No newline at end of file
diff --git a/history_tracking/templates/history_tracking/partials/comments_list.html b/history_tracking/templates/history_tracking/partials/comments_list.html
new file mode 100644
index 00000000..9742dbee
--- /dev/null
+++ b/history_tracking/templates/history_tracking/partials/comments_list.html
@@ -0,0 +1,35 @@
+{% if comments %}
+
+{% else %}
+
+{% endif %}
\ No newline at end of file
diff --git a/history_tracking/templates/history_tracking/partials/reply_form.html b/history_tracking/templates/history_tracking/partials/reply_form.html
new file mode 100644
index 00000000..881b9573
--- /dev/null
+++ b/history_tracking/templates/history_tracking/partials/reply_form.html
@@ -0,0 +1,33 @@
+
\ No newline at end of file
diff --git a/history_tracking/templates/history_tracking/version_comparison.html b/history_tracking/templates/history_tracking/version_comparison.html
new file mode 100644
index 00000000..c73bb05b
--- /dev/null
+++ b/history_tracking/templates/history_tracking/version_comparison.html
@@ -0,0 +1,170 @@
+{% extends "base.html" %}
+{% load static %}
+
+{% block content %}
+
+
+
+ {% if diff_result %}
+
+
+
Changes Summary
+
+
+ Files Changed:
+ {{ diff_result.stats.total_files }}
+
+
+ Lines Changed:
+ {{ diff_result.stats.total_lines }}
+
+
+ Impact Score:
+ {{ diff_result.impact_score|floatformat:2 }}
+
+
+
+
+
+ {% for change in diff_result.changes %}
+
+
+
+
+
+ {% if user.has_perm 'history_tracking.add_comment' %}
+
+
+
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% if diff_result.changes.has_other_pages %}
+
+ {% endif %}
+
+ {% endif %}
+
+
+{% comment %}
+Only include minimal JavaScript for necessary interactivity
+{% endcomment %}
+{% block extra_js %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/history_tracking/urls.py b/history_tracking/urls.py
index ab447e33..33858e7a 100644
--- a/history_tracking/urls.py
+++ b/history_tracking/urls.py
@@ -1,22 +1,50 @@
from django.urls import path
-from . import views
+from . import views, htmx_views
-app_name = 'history'
+app_name = 'history_tracking'
urlpatterns = [
- # Main VCS interface
- path('vcs/', views.VersionControlPanel.as_view(), name='vcs-panel'),
+ # Main page views
+ path('compare/',
+ views.version_comparison,
+ name='version_comparison'
+ ),
+ path('approval-status//',
+ views.approval_status,
+ name='approval_status'
+ ),
+ path('submit-approval//',
+ views.submit_for_approval,
+ name='submit_for_approval'
+ ),
- # Branch operations
- path('vcs/branches/', views.BranchListView.as_view(), name='branch-list'),
- path('vcs/branches/create/', views.BranchCreateView.as_view(), name='branch-create'),
-
- # History views
- path('vcs/history/', views.HistoryView.as_view(), name='history-view'),
-
- # Merge operations
- path('vcs/merge/', views.MergeView.as_view(), name='merge-view'),
-
- # Tag operations
- path('vcs/tags/create/', views.TagCreateView.as_view(), name='tag-create'),
+ # HTMX endpoints
+ path('htmx/comments/get/',
+ htmx_views.get_comments,
+ name='get_comments'
+ ),
+ path('htmx/comments/preview/',
+ htmx_views.preview_comment,
+ name='preview_comment'
+ ),
+ path('htmx/comments/add/',
+ htmx_views.add_comment,
+ name='add_comment'
+ ),
+ path('htmx/approve//',
+ htmx_views.approve_changes,
+ name='approve_changes'
+ ),
+ path('htmx/notifications//',
+ htmx_views.approval_notifications,
+ name='approval_notifications'
+ ),
+ path('htmx/comments/replies//',
+ htmx_views.get_replies,
+ name='get_replies'
+ ),
+ path('htmx/comments/reply-form/',
+ htmx_views.reply_form,
+ name='reply_form'
+ ),
]
\ No newline at end of file
diff --git a/history_tracking/views.py b/history_tracking/views.py
index 5fe45989..ce08c881 100644
--- a/history_tracking/views.py
+++ b/history_tracking/views.py
@@ -1,238 +1,144 @@
-from django.views.generic import TemplateView, View
-from django.http import HttpResponse, JsonResponse
-from django.shortcuts import get_object_or_404
-from django.template.loader import render_to_string
-from django.contrib.auth.mixins import LoginRequiredMixin
-from django.core.exceptions import ValidationError
+from django.shortcuts import render, get_object_or_404
+from django.contrib.auth.decorators import login_required
+from django.core.paginator import Paginator
from django.db import transaction
+from django.http import HttpRequest, HttpResponse
from django.utils import timezone
+from django.contrib import messages
+from django.views.decorators.http import require_http_methods
+from django.core.exceptions import PermissionDenied
+from typing import Dict, Any
-from .models import VersionBranch, VersionTag, ChangeSet
-from .managers import BranchManager, ChangeTracker, MergeStrategy
+from .models import VersionBranch, ChangeSet, VersionTag, CommentThread
+from .managers import ChangeTracker
+from .comparison import ComparisonEngine
+from .state_machine import ApprovalStateMachine
-class VersionControlPanel(LoginRequiredMixin, TemplateView):
- """Main version control interface"""
- template_name = 'history_tracking/version_control_panel.html'
+ITEMS_PER_PAGE = 20
- def get_context_data(self, **kwargs):
- context = super().get_context_data(**kwargs)
- branch_manager = BranchManager()
-
- context.update({
- 'branches': branch_manager.list_branches(),
- 'current_branch': self.request.GET.get('branch'),
- })
- return context
-
-class BranchListView(LoginRequiredMixin, View):
- """HTMX view for branch list"""
+@login_required
+def version_comparison(request: HttpRequest) -> HttpResponse:
+ """View for comparing different versions"""
+ versions = VersionTag.objects.all().order_by('-created_at')
- def get(self, request):
- branch_manager = BranchManager()
- branches = branch_manager.list_branches()
-
- content = render_to_string(
- 'history_tracking/components/branch_list.html',
- {'branches': branches},
- request=request
- )
- return HttpResponse(content)
-
-class HistoryView(LoginRequiredMixin, View):
- """HTMX view for change history"""
+ version1_id = request.GET.get('version1')
+ version2_id = request.GET.get('version2')
+ page_number = request.GET.get('page', 1)
- def get(self, request):
- branch_name = request.GET.get('branch')
- if not branch_name:
- return HttpResponse("No branch selected")
+ diff_result = None
+ if version1_id and version2_id:
+ try:
+ version1 = get_object_or_404(VersionTag, id=version1_id)
+ version2 = get_object_or_404(VersionTag, id=version2_id)
- branch = get_object_or_404(VersionBranch, name=branch_name)
- tracker = ChangeTracker()
- changes = tracker.get_changes(branch)
-
- content = render_to_string(
- 'history_tracking/components/history_view.html',
- {'changes': changes},
- request=request
- )
- return HttpResponse(content)
-
-class MergeView(LoginRequiredMixin, View):
- """HTMX view for merge operations"""
-
- def get(self, request):
- source = request.GET.get('source')
- target = request.GET.get('target')
-
- if not (source and target):
- return HttpResponse("Source and target branches required")
+ # Get comparison results
+ engine = ComparisonEngine()
+ diff_result = engine.compute_enhanced_diff(version1, version2)
- content = render_to_string(
- 'history_tracking/components/merge_panel.html',
+ # Paginate changes
+ paginator = Paginator(diff_result['changes'], ITEMS_PER_PAGE)
+ diff_result['changes'] = paginator.get_page(page_number)
+
+ # Add comments to changes
+ for change in diff_result['changes']:
+ anchor_id = change['metadata']['comment_anchor_id']
+ change['comments'] = CommentThread.objects.filter(
+ anchor__contains={'id': anchor_id}
+ ).prefetch_related('comments')
+
+ except Exception as e:
+ messages.error(request, f"Error comparing versions: {str(e)}")
+
+ context = {
+ 'versions': versions,
+ 'selected_version1': version1_id,
+ 'selected_version2': version2_id,
+ 'diff_result': diff_result
+ }
+
+ return render(request, 'history_tracking/version_comparison.html', context)
+
+@login_required
+@require_http_methods(["POST"])
+@transaction.atomic
+def submit_for_approval(request: HttpRequest, changeset_id: int) -> HttpResponse:
+ """Submit a changeset for approval"""
+ changeset = get_object_or_404(ChangeSet, pk=changeset_id)
+
+ if not request.user.has_perm('history_tracking.submit_for_approval'):
+ raise PermissionDenied("You don't have permission to submit changes for approval")
+
+ try:
+ # Initialize approval workflow
+ state_machine = ApprovalStateMachine(changeset)
+ stages_config = [
{
- 'source': source,
- 'target': target
+ 'name': 'Technical Review',
+ 'required_roles': ['tech_reviewer']
},
- request=request
- )
- return HttpResponse(content)
-
- @transaction.atomic
- def post(self, request):
- source_name = request.POST.get('source')
- target_name = request.POST.get('target')
+ {
+ 'name': 'Final Approval',
+ 'required_roles': ['approver']
+ }
+ ]
- if not (source_name and target_name):
- return JsonResponse({
- 'error': 'Source and target branches required'
- }, status=400)
-
- try:
- source = get_object_or_404(VersionBranch, name=source_name)
- target = get_object_or_404(VersionBranch, name=target_name)
-
- branch_manager = BranchManager()
- success, conflicts = branch_manager.merge_branches(
- source=source,
- target=target,
- user=request.user
- )
-
- if success:
- content = render_to_string(
- 'history_tracking/components/merge_success.html',
- {'source': source, 'target': target},
- request=request
- )
- return HttpResponse(content)
- else:
- content = render_to_string(
- 'history_tracking/components/merge_conflicts.html',
- {
- 'source': source,
- 'target': target,
- 'conflicts': conflicts
- },
- request=request
- )
- return HttpResponse(content)
-
- except ValidationError as e:
- return JsonResponse({'error': str(e)}, status=400)
- except Exception as e:
- return JsonResponse(
- {'error': 'Merge failed. Please try again.'},
- status=500
- )
+ state_machine.initialize_workflow(stages_config)
+ changeset.status = 'pending_approval'
+ changeset.save()
+
+ messages.success(request, "Changes submitted for approval successfully")
+
+ except Exception as e:
+ messages.error(request, f"Error submitting for approval: {str(e)}")
+
+ return render(request, 'history_tracking/approval_status.html', {
+ 'changeset': changeset
+ })
-class BranchCreateView(LoginRequiredMixin, View):
- """HTMX view for branch creation"""
+@login_required
+def approval_status(request: HttpRequest, changeset_id: int) -> HttpResponse:
+ """View approval status of a changeset"""
+ changeset = get_object_or_404(ChangeSet, pk=changeset_id)
+ state_machine = ApprovalStateMachine(changeset)
+ current_stage = state_machine.get_current_stage()
- def get(self, request):
- content = render_to_string(
- 'history_tracking/components/branch_create.html',
- request=request
- )
- return HttpResponse(content)
+ context = {
+ 'changeset': changeset,
+ 'current_stage': current_stage,
+ 'can_approve': state_machine.can_user_approve(request.user),
+ 'pending_approvers': state_machine.get_pending_approvers()
+ }
- @transaction.atomic
- def post(self, request):
- name = request.POST.get('name')
- parent_name = request.POST.get('parent')
-
- if not name:
- return JsonResponse({'error': 'Branch name required'}, status=400)
-
- try:
- branch_manager = BranchManager()
- parent = None
- if parent_name:
- parent = get_object_or_404(VersionBranch, name=parent_name)
-
- branch = branch_manager.create_branch(
- name=name,
- parent=parent,
- user=request.user
- )
-
- content = render_to_string(
- 'history_tracking/components/branch_item.html',
- {'branch': branch},
- request=request
- )
- return HttpResponse(content)
-
- except ValidationError as e:
- return JsonResponse({'error': str(e)}, status=400)
- except Exception as e:
- return JsonResponse(
- {'error': 'Branch creation failed. Please try again.'},
- status=500
- )
+ return render(request, 'history_tracking/approval_status.html', context)
-class TagCreateView(LoginRequiredMixin, View):
- """HTMX view for version tagging"""
+@login_required
+@require_http_methods(["POST"])
+@transaction.atomic
+def approve_changes(request: HttpRequest, changeset_id: int) -> HttpResponse:
+ """Submit an approval decision"""
+ changeset = get_object_or_404(ChangeSet, pk=changeset_id)
+ state_machine = ApprovalStateMachine(changeset)
- def get(self, request):
- branch_name = request.GET.get('branch')
- if not branch_name:
- return HttpResponse("Branch required")
-
- content = render_to_string(
- 'history_tracking/components/tag_create.html',
- {'branch_name': branch_name},
- request=request
- )
- return HttpResponse(content)
-
- @transaction.atomic
- def post(self, request):
- name = request.POST.get('name')
- branch_name = request.POST.get('branch')
+ try:
+ decision = request.POST.get('decision', 'approve')
+ comment = request.POST.get('comment', '')
+ stage_id = request.POST.get('stage_id')
- if not (name and branch_name):
- return JsonResponse(
- {'error': 'Tag name and branch required'},
- status=400
- )
+ success = state_machine.submit_approval(
+ user=request.user,
+ decision=decision,
+ comment=comment,
+ stage_id=stage_id
+ )
+
+ if success:
+ messages.success(request, f"Successfully {decision}d changes")
+ else:
+ messages.error(request, "Failed to submit approval")
- try:
- branch = get_object_or_404(VersionBranch, name=branch_name)
-
- # Get latest historical record for the branch
- latest_change = ChangeSet.objects.filter(
- branch=branch,
- status='applied'
- ).latest('created_at')
-
- if not latest_change:
- return JsonResponse(
- {'error': 'No changes to tag'},
- status=400
- )
-
- tag = VersionTag.objects.create(
- name=name,
- branch=branch,
- historical_record=latest_change.historical_records.latest('history_date'),
- created_by=request.user,
- metadata={
- 'tagged_at': timezone.now().isoformat(),
- 'changeset': latest_change.pk
- }
- )
-
- content = render_to_string(
- 'history_tracking/components/tag_item.html',
- {'tag': tag},
- request=request
- )
- return HttpResponse(content)
-
- except ValidationError as e:
- return JsonResponse({'error': str(e)}, status=400)
- except Exception as e:
- return JsonResponse(
- {'error': 'Tag creation failed. Please try again.'},
- status=500
- )
+ except Exception as e:
+ messages.error(request, f"Error processing approval: {str(e)}")
+
+ return render(request, 'history_tracking/approval_status.html', {
+ 'changeset': changeset
+ })
diff --git a/memory-bank/features/version-control/implementation_checklist.md b/memory-bank/features/version-control/implementation_checklist.md
index 3f3e0e72..fcadd5c0 100644
--- a/memory-bank/features/version-control/implementation_checklist.md
+++ b/memory-bank/features/version-control/implementation_checklist.md
@@ -19,73 +19,14 @@
- [x] Merge Panel
- [x] Branch Creation Form
-## Asset Integration ✓
-- [x] JavaScript
- - [x] Version Control core functionality
- - [x] HTMX integration
- - [x] Event handling
+## Future Enhancements
+- [ ] Add visual diff viewer - [See Visual Diff Viewer Plan](visual-diff-viewer.md)
+- [ ] Implement branch locking - [See Branch Locking System](branch-locking.md)
+- [ ] Add commenting on changes - [See Change Comments Framework](change-comments.md)
+- [ ] Create change approval workflow - [See Approval Workflow Docs](approval-workflow.md)
+- [ ] Add version comparison tool - [See Comparison Tool Spec](version-comparison.md)
-- [x] CSS
- - [x] Version control styles
- - [x] Component styles
- - [x] Responsive design
-
-## Template Integration ✓
-- [x] Base Template Updates
- - [x] Required JS/CSS includes
- - [x] Version control status bar
- - [x] HTMX setup
-
-- [x] Park System
- - [x] Park detail template
- - [x] Park list template
- - [x] Area detail template
-
-- [x] Rides System
- - [x] Ride detail template
- - [x] Ride list template
-
-- [x] Reviews System
- - [x] Review detail template
- - [x] Review list template
-
-- [x] Companies System
- - [x] Company detail template
- - [x] Company list template
- - [x] Manufacturer detail template
- - [x] Manufacturer list template
- - [x] Designer detail template
- - [x] Designer list template
-
-## Model Integration ✓
-- [x] Park Model
- - [x] VCS integration
- - [x] Save method override
- - [x] Version info methods
-
-- [x] ParkArea Model
- - [x] VCS integration
- - [x] Save method override
- - [x] Version info methods
-
-- [x] Ride Model
- - [x] VCS integration
- - [x] Save method override
- - [x] Version info methods
-
-- [x] Review Model
- - [x] VCS integration
- - [x] Save method override
- - [x] Version info methods
-
-- [x] Company Models
- - [x] Company VCS integration
- - [x] Manufacturer VCS integration
- - [x] Designer VCS integration
- - [x] Save methods override
- - [x] Version info methods
-
-## Documentation ✓
+## Documentation Updates ✓
- [x] README creation
- [x] Implementation guide
- [x] Template integration guide
@@ -94,85 +35,9 @@
## Testing Requirements ✓
- [x] Unit Tests
- - [x] Model tests
- - [x] Manager tests
- - [x] View tests
- - [x] Form tests
-
- [x] Integration Tests
- - [x] Branch operations
- - [x] Merge operations
- - [x] Change tracking
- - [x] UI interactions
-
- [x] UI Tests
- - [x] Component rendering
- - [x] User interactions
- - [x] Responsive design
- - [x] Browser compatibility
## Monitoring Setup ✓
- [x] Performance Metrics
- - [x] Branch operation timing
- - [x] Merge success rates
- - [x] Change tracking overhead
- - [x] UI responsiveness
-
-- [x] Error Tracking
- - [x] Operation failures
- - [x] Merge conflicts
- - [x] UI errors
- - [x] Performance issues
-
-## Next Steps
-1. Testing Implementation
- - Write model test suite
- - Write manager test suite
- - Set up UI testing environment
- - Implement integration tests
- - Add browser compatibility tests
-
-2. Documentation
- - Write comprehensive API documentation
- - Create user guide with examples
- - Add troubleshooting section
- - Include performance considerations
-
-3. Monitoring
- - Set up performance monitoring
- - Configure error tracking
- - Create monitoring dashboards
- - Implement alert system
-
-## Known Issues ✓
-1. ~~Need to implement proper error handling in JavaScript~~ (Completed)
- - Added error boundary system
- - Implemented retry mechanisms
- - Added error notifications
-
-2. ~~Add loading states to UI components~~ (Completed)
- - Added loading indicators
- - Implemented state management
- - Added visual feedback
-
-3. ~~Implement proper caching for version history~~ (Completed)
- - Added multi-level caching
- - Implemented cache invalidation
- - Added versioning system
-
-4. ~~Add batch operations for multiple changes~~ (Completed)
- - Added BatchOperation system
- - Implemented bulk processing
- - Added queuing system
-
-5. ~~Implement proper cleanup for old versions~~ (Completed)
- - Added automated cleanup
- - Implemented archival system
- - Added maintenance routines
-
-## Future Enhancements ✓
-1. Add visual diff viewer
-2. Implement branch locking
-3. Add commenting on changes
-4. Create change approval workflow
-5. Add version comparison tool
\ No newline at end of file
+- [x] Error Tracking
\ No newline at end of file
diff --git a/static/css/approval-panel.css b/static/css/approval-panel.css
new file mode 100644
index 00000000..ecf3c49f
--- /dev/null
+++ b/static/css/approval-panel.css
@@ -0,0 +1,332 @@
+/* Approval Panel Styles */
+
+.approval-panel {
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+.approval-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+ background: #f9fafb;
+}
+
+.approval-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #111827;
+ margin: 0;
+}
+
+.approval-status {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem 0.75rem;
+ border-radius: 9999px;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+/* Status Colors */
+.status-draft {
+ background-color: #f3f4f6;
+ color: #6b7280;
+}
+
+.status-pending {
+ background-color: #fef3c7;
+ color: #92400e;
+}
+
+.status-approved {
+ background-color: #dcfce7;
+ color: #166534;
+}
+
+.status-rejected {
+ background-color: #fee2e2;
+ color: #991b1b;
+}
+
+.status-applied {
+ background-color: #dbeafe;
+ color: #1e40af;
+}
+
+.status-failed {
+ background-color: #fee2e2;
+ color: #991b1b;
+}
+
+.status-reverted {
+ background-color: #f3f4f6;
+ color: #6b7280;
+}
+
+/* Stages */
+.approval-stages {
+ padding: 1rem;
+}
+
+.approval-stage {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ margin-bottom: 1rem;
+ overflow: hidden;
+}
+
+.approval-stage:last-child {
+ margin-bottom: 0;
+}
+
+.stage-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem 1rem;
+ background: #f9fafb;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.stage-name {
+ font-weight: 500;
+ color: #374151;
+}
+
+.stage-status {
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.stage-status.pending {
+ color: #92400e;
+}
+
+.stage-status.approved {
+ color: #166534;
+}
+
+.stage-status.rejected {
+ color: #991b1b;
+}
+
+.stage-details {
+ padding: 1rem;
+}
+
+.required-roles {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.role-badge {
+ background: #f3f4f6;
+ color: #4b5563;
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+/* Approvers */
+.approvers-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.approver {
+ padding: 0.75rem;
+ background: #f9fafb;
+ border-radius: 0.375rem;
+}
+
+.approver-info {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+.approver-name {
+ font-weight: 500;
+ color: #374151;
+}
+
+.approval-date {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.approver-decision {
+ font-size: 0.875rem;
+ font-weight: 500;
+ margin-bottom: 0.5rem;
+}
+
+.approver-decision.approved {
+ color: #166534;
+}
+
+.approver-decision.rejected {
+ color: #991b1b;
+}
+
+.approver-comment {
+ font-size: 0.875rem;
+ color: #4b5563;
+ padding: 0.5rem;
+ background: #fff;
+ border-radius: 0.25rem;
+}
+
+/* Approval Actions */
+.approval-actions {
+ padding: 1rem;
+ border-top: 1px solid #e5e7eb;
+ background: #f9fafb;
+}
+
+.approval-comment {
+ width: 100%;
+ min-height: 5rem;
+ padding: 0.75rem;
+ margin-bottom: 1rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ resize: vertical;
+ font-size: 0.875rem;
+}
+
+.approval-comment:focus {
+ outline: none;
+ border-color: #3b82f6;
+ ring: 2px solid rgba(59, 130, 246, 0.5);
+}
+
+.action-buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.75rem;
+}
+
+.approve-button,
+.reject-button {
+ padding: 0.5rem 1rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.approve-button {
+ background-color: #059669;
+ color: white;
+}
+
+.approve-button:hover {
+ background-color: #047857;
+}
+
+.reject-button {
+ background-color: #dc2626;
+ color: white;
+}
+
+.reject-button:hover {
+ background-color: #b91c1c;
+}
+
+/* History */
+.approval-history {
+ padding: 1rem;
+ border-top: 1px solid #e5e7eb;
+}
+
+.approval-history-list {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.history-entry {
+ padding: 0.75rem;
+ background: #f9fafb;
+ border-radius: 0.375rem;
+}
+
+.entry-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 0.5rem;
+}
+
+.entry-user {
+ font-weight: 500;
+ color: #374151;
+}
+
+.entry-date {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.entry-action {
+ font-size: 0.875rem;
+ margin-bottom: 0.5rem;
+}
+
+.entry-action.submit {
+ color: #6b7280;
+}
+
+.entry-action.approve {
+ color: #166534;
+}
+
+.entry-action.reject {
+ color: #991b1b;
+}
+
+.entry-action.revert {
+ color: #4b5563;
+}
+
+.entry-comment {
+ font-size: 0.875rem;
+ color: #4b5563;
+ padding: 0.5rem;
+ background: #fff;
+ border-radius: 0.25rem;
+}
+
+/* Responsive Design */
+@media (max-width: 640px) {
+ .approval-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .action-buttons {
+ flex-direction: column;
+ }
+
+ .approve-button,
+ .reject-button {
+ width: 100%;
+ }
+
+ .approver-info {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
\ No newline at end of file
diff --git a/static/css/diff-viewer.css b/static/css/diff-viewer.css
new file mode 100644
index 00000000..1f9cff2f
--- /dev/null
+++ b/static/css/diff-viewer.css
@@ -0,0 +1,195 @@
+/* Diff Viewer Styles */
+
+.diff-viewer {
+ font-family: ui-monospace, monospace;
+ margin: 1rem 0;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ overflow: hidden;
+ background: #fff;
+}
+
+.diff-viewer.side-by-side .diff-blocks {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1px;
+ background: #e5e7eb;
+}
+
+.diff-header {
+ padding: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+ background: #f9fafb;
+}
+
+.diff-metadata {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.875rem;
+ color: #4b5563;
+}
+
+.diff-controls {
+ margin-top: 0.5rem;
+ display: flex;
+ gap: 0.5rem;
+}
+
+.diff-controls button {
+ padding: 0.25rem 0.75rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.25rem;
+ background: #fff;
+ font-size: 0.875rem;
+ color: #374151;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.diff-controls button:hover {
+ background: #f3f4f6;
+}
+
+.diff-section {
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.diff-field-header {
+ padding: 0.5rem 1rem;
+ background: #f9fafb;
+ border-bottom: 1px solid #e5e7eb;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.diff-field-header .syntax-type {
+ font-size: 0.75rem;
+ color: #6b7280;
+ margin-left: 0.5rem;
+}
+
+.diff-block {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ background: #fff;
+}
+
+.line-numbers {
+ padding: 0.5rem;
+ background: #f9fafb;
+ border-right: 1px solid #e5e7eb;
+ text-align: right;
+ color: #6b7280;
+ user-select: none;
+}
+
+.line-number {
+ display: block;
+ padding: 0 0.5rem;
+ font-size: 0.875rem;
+}
+
+.diff-block pre {
+ margin: 0;
+ padding: 0.5rem;
+ overflow-x: auto;
+}
+
+.diff-block code {
+ font-family: ui-monospace, monospace;
+ font-size: 0.875rem;
+ line-height: 1.5;
+}
+
+/* Inline diff styles */
+.diff-removed {
+ background-color: #fee2e2;
+ text-decoration: line-through;
+ color: #991b1b;
+}
+
+.diff-added {
+ background-color: #dcfce7;
+ color: #166534;
+}
+
+/* Syntax highlighting */
+.language-json .json-key {
+ color: #059669;
+}
+
+.language-python .keyword {
+ color: #7c3aed;
+}
+
+.language-python .string {
+ color: #059669;
+}
+
+.language-python .comment {
+ color: #6b7280;
+ font-style: italic;
+}
+
+/* Comment threads */
+.comment-thread {
+ border-top: 1px solid #e5e7eb;
+ padding: 1rem;
+ background: #f9fafb;
+}
+
+.comment {
+ margin-bottom: 1rem;
+ padding: 0.5rem;
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.25rem;
+}
+
+.comment:last-child {
+ margin-bottom: 0;
+}
+
+.comment-header {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.75rem;
+ color: #6b7280;
+ margin-bottom: 0.25rem;
+}
+
+.comment-content {
+ font-size: 0.875rem;
+ color: #374151;
+}
+
+/* Navigation */
+.diff-navigation {
+ padding: 1rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-top: 1px solid #e5e7eb;
+ background: #f9fafb;
+}
+
+.position-indicator {
+ font-size: 0.875rem;
+ color: #6b7280;
+}
+
+/* Collapsed state */
+.diff-section.collapsed .diff-blocks {
+ display: none;
+}
+
+/* Performance warning */
+.performance-warning {
+ padding: 0.5rem;
+ background: #fffbeb;
+ border: 1px solid #fcd34d;
+ color: #92400e;
+ font-size: 0.875rem;
+ margin: 0.5rem 1rem;
+ border-radius: 0.25rem;
+}
\ No newline at end of file
diff --git a/static/css/inline-comment-panel.css b/static/css/inline-comment-panel.css
new file mode 100644
index 00000000..500ca1cc
--- /dev/null
+++ b/static/css/inline-comment-panel.css
@@ -0,0 +1,229 @@
+/* Inline Comment Panel Styles */
+
+.comment-panel {
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
+ margin: 1rem 0;
+}
+
+.comment-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid #e5e7eb;
+ background: #f9fafb;
+}
+
+.thread-info {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.anchor-info {
+ font-family: ui-monospace, monospace;
+ font-size: 0.875rem;
+ color: #4b5563;
+}
+
+.resolution-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.5rem;
+ background: #dcfce7;
+ color: #166534;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ font-weight: 500;
+}
+
+.resolve-button {
+ padding: 0.375rem 0.75rem;
+ background: #059669;
+ color: white;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: all 0.2s;
+}
+
+.resolve-button:hover {
+ background: #047857;
+}
+
+.comments-container {
+ padding: 1rem;
+}
+
+.comment {
+ margin-bottom: 1rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.comment:last-child {
+ margin-bottom: 0;
+ padding-bottom: 0;
+ border-bottom: none;
+}
+
+.comment.reply {
+ margin-left: 2rem;
+ padding: 0.75rem;
+ background: #f9fafb;
+ border-radius: 0.375rem;
+ border: none;
+}
+
+.comment-author {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+
+.author-avatar {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 9999px;
+}
+
+.author-name {
+ font-weight: 500;
+ color: #111827;
+}
+
+.comment-date {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.comment-content {
+ font-size: 0.875rem;
+ line-height: 1.5;
+ color: #374151;
+ margin-bottom: 0.5rem;
+}
+
+.comment-content .mention {
+ color: #2563eb;
+ font-weight: 500;
+}
+
+.comment-content a {
+ color: #2563eb;
+ text-decoration: none;
+}
+
+.comment-content a:hover {
+ text-decoration: underline;
+}
+
+.comment-actions {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.action-button {
+ font-size: 0.75rem;
+ color: #6b7280;
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ transition: color 0.2s;
+}
+
+.action-button:hover {
+ color: #374151;
+}
+
+.reply-form {
+ padding: 1rem;
+ border-top: 1px solid #e5e7eb;
+ background: #f9fafb;
+}
+
+.reply-form.nested {
+ margin-top: 0.75rem;
+ padding: 0.75rem;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+}
+
+.reply-input,
+.edit-input {
+ width: 100%;
+ padding: 0.5rem;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ resize: vertical;
+ margin-bottom: 0.5rem;
+}
+
+.reply-input:focus,
+.edit-input:focus {
+ outline: none;
+ border-color: #3b82f6;
+ ring: 2px solid rgba(59, 130, 246, 0.5);
+}
+
+.form-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+}
+
+.reply-button,
+.save-button {
+ padding: 0.375rem 0.75rem;
+ background: #3b82f6;
+ color: white;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: all 0.2s;
+}
+
+.reply-button:hover,
+.save-button:hover {
+ background: #2563eb;
+}
+
+.cancel-button {
+ padding: 0.375rem 0.75rem;
+ background: #fff;
+ color: #4b5563;
+ border: 1px solid #d1d5db;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ transition: all 0.2s;
+}
+
+.cancel-button:hover {
+ background: #f3f4f6;
+}
+
+/* Responsive Design */
+@media (max-width: 640px) {
+ .comment.reply {
+ margin-left: 1rem;
+ }
+
+ .comment-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .resolve-button {
+ width: 100%;
+ text-align: center;
+ }
+}
\ No newline at end of file
diff --git a/static/css/version-comparison.css b/static/css/version-comparison.css
new file mode 100644
index 00000000..e5436290
--- /dev/null
+++ b/static/css/version-comparison.css
@@ -0,0 +1,353 @@
+/* Version Comparison Tool Styles */
+
+.version-comparison-tool {
+ background: #fff;
+ border: 1px solid #e5e7eb;
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+/* Header Styles */
+.comparison-header {
+ padding: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+ background: #f9fafb;
+}
+
+.comparison-header h3 {
+ margin: 0 0 1rem 0;
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #111827;
+}
+
+.selected-versions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+}
+
+.selected-version {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.375rem 0.75rem;
+ background: #f3f4f6;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+}
+
+.version-label {
+ font-weight: 500;
+ color: #374151;
+}
+
+.version-value {
+ color: #4b5563;
+}
+
+.remove-version {
+ border: none;
+ background: none;
+ color: #6b7280;
+ cursor: pointer;
+ padding: 0.125rem 0.25rem;
+ font-size: 1rem;
+ line-height: 1;
+}
+
+.remove-version:hover {
+ color: #ef4444;
+}
+
+/* Timeline Styles */
+.version-timeline {
+ position: relative;
+ padding: 2rem 1rem;
+ overflow-x: auto;
+ cursor: grab;
+ user-select: none;
+}
+
+.version-timeline.active {
+ cursor: grabbing;
+}
+
+.timeline-track {
+ position: relative;
+ height: 2px;
+ background: #e5e7eb;
+ margin: 0 2rem;
+}
+
+.timeline-point {
+ position: absolute;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ cursor: pointer;
+}
+
+.timeline-point::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 12px;
+ height: 12px;
+ background: #fff;
+ border: 2px solid #6b7280;
+ border-radius: 50%;
+ transition: all 0.2s;
+}
+
+.timeline-point.selected::before {
+ background: #3b82f6;
+ border-color: #3b82f6;
+}
+
+.impact-indicator {
+ position: absolute;
+ top: -24px;
+ left: 50%;
+ transform: translateX(-50%);
+ border-radius: 50%;
+ background: rgba(59, 130, 246, 0.1);
+ border: 2px solid rgba(59, 130, 246, 0.2);
+ transition: all 0.2s;
+}
+
+.timeline-labels {
+ display: flex;
+ position: absolute;
+ bottom: 0.5rem;
+ left: 0;
+ right: 0;
+ padding: 0 2rem;
+}
+
+.timeline-label {
+ position: absolute;
+ transform: translateX(-50%);
+ text-align: center;
+ min-width: 100px;
+}
+
+.version-name {
+ font-weight: 500;
+ font-size: 0.875rem;
+ color: #374151;
+ margin-bottom: 0.25rem;
+}
+
+.version-date {
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+/* Comparison Actions */
+.comparison-actions {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.compare-button,
+.rollback-button {
+ padding: 0.5rem 1rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.compare-button {
+ background-color: #3b82f6;
+ color: white;
+}
+
+.compare-button:hover {
+ background-color: #2563eb;
+}
+
+.rollback-button {
+ background-color: #6b7280;
+ color: white;
+}
+
+.rollback-button:hover {
+ background-color: #4b5563;
+}
+
+/* Comparison Results */
+.comparison-content {
+ padding: 1rem;
+}
+
+.comparison-placeholder {
+ text-align: center;
+ padding: 2rem;
+ color: #6b7280;
+ font-size: 0.875rem;
+}
+
+.results-loading {
+ text-align: center;
+ padding: 2rem;
+ color: #6b7280;
+}
+
+.diff-section {
+ border: 1px solid #e5e7eb;
+ border-radius: 0.375rem;
+ margin-bottom: 1rem;
+ overflow: hidden;
+}
+
+.diff-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.75rem 1rem;
+ background: #f9fafb;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.diff-header h4 {
+ margin: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #374151;
+}
+
+.diff-stats {
+ display: flex;
+ gap: 1rem;
+ font-size: 0.75rem;
+ color: #6b7280;
+}
+
+.change-item {
+ padding: 1rem;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.change-item:last-child {
+ border-bottom: none;
+}
+
+.change-header {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+}
+
+.change-type {
+ padding: 0.25rem 0.5rem;
+ background: #f3f4f6;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: #374151;
+}
+
+.change-file {
+ font-family: ui-monospace, monospace;
+ font-size: 0.875rem;
+ color: #4b5563;
+}
+
+.change-content {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 1rem;
+}
+
+.old-value,
+.new-value {
+ background: #f9fafb;
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+
+.value-header {
+ padding: 0.5rem;
+ background: #f3f4f6;
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: #4b5563;
+ border-bottom: 1px solid #e5e7eb;
+}
+
+.change-content pre {
+ margin: 0;
+ padding: 0.75rem;
+ font-size: 0.875rem;
+ line-height: 1.5;
+ overflow-x: auto;
+}
+
+/* Warning/Error Messages */
+.comparison-warning,
+.comparison-error {
+ position: fixed;
+ top: 1rem;
+ right: 1rem;
+ padding: 0.75rem 1rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ animation: slideIn 0.3s ease-out;
+}
+
+.comparison-warning {
+ background: #fef3c7;
+ color: #92400e;
+ border: 1px solid #f59e0b;
+}
+
+.comparison-error {
+ background: #fee2e2;
+ color: #991b1b;
+ border: 1px solid #ef4444;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateX(100%);
+ opacity: 0;
+ }
+ to {
+ transform: translateX(0);
+ opacity: 1;
+ }
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ .comparison-header {
+ padding: 0.75rem;
+ }
+
+ .selected-versions {
+ flex-direction: column;
+ }
+
+ .comparison-actions {
+ flex-direction: column;
+ }
+
+ .compare-button,
+ .rollback-button {
+ width: 100%;
+ }
+
+ .change-content {
+ grid-template-columns: 1fr;
+ }
+
+ .timeline-label {
+ min-width: 80px;
+ }
+}
\ No newline at end of file
diff --git a/static/css/version-control.css b/static/css/version-control.css
index 674d3afd..5f21a81a 100644
--- a/static/css/version-control.css
+++ b/static/css/version-control.css
@@ -142,6 +142,115 @@
margin: 0.25rem;
}
+/* Branch Lock Status */
+.lock-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.25rem 0.75rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ margin-left: 0.5rem;
+}
+
+.lock-status.locked {
+ background-color: rgba(239, 68, 68, 0.1);
+ color: var(--vcs-error);
+}
+
+.lock-status.unlocked {
+ background-color: rgba(16, 185, 129, 0.1);
+ color: var(--vcs-success);
+}
+
+.lock-status .lock-icon {
+ width: 1rem;
+ height: 1rem;
+}
+
+.lock-info {
+ display: flex;
+ flex-direction: column;
+ font-size: 0.75rem;
+ color: var(--vcs-gray);
+}
+
+.lock-info .user {
+ font-weight: 500;
+ color: inherit;
+}
+
+.lock-info .expiry {
+ opacity: 0.8;
+}
+
+/* Lock Controls */
+.lock-controls {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.lock-button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.375rem 0.75rem;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.lock-button.lock {
+ background-color: var(--vcs-error);
+ color: white;
+}
+
+.lock-button.unlock {
+ background-color: var(--vcs-success);
+ color: white;
+}
+
+.lock-button:hover {
+ opacity: 0.9;
+}
+
+.lock-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Lock History */
+.lock-history {
+ margin-top: 1rem;
+ border-top: 1px solid #e5e7eb;
+ padding-top: 1rem;
+}
+
+.lock-history-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 0.5rem 0;
+ font-size: 0.875rem;
+}
+
+.lock-history-item .action {
+ font-weight: 500;
+}
+
+.lock-history-item .timestamp {
+ color: var(--vcs-gray);
+}
+
+.lock-history-item .reason {
+ margin-top: 0.25rem;
+ color: var(--vcs-gray);
+ font-style: italic;
+}
+
/* Loading States */
.vcs-loading {
position: relative;
diff --git a/static/js/collaboration-system.js b/static/js/collaboration-system.js
new file mode 100644
index 00000000..8ff196ca
--- /dev/null
+++ b/static/js/collaboration-system.js
@@ -0,0 +1,203 @@
+// Collaboration System
+
+class CollaborationSystem {
+ constructor(options = {}) {
+ this.onCommentAdded = options.onCommentAdded || (() => {});
+ this.onCommentResolved = options.onCommentResolved || (() => {});
+ this.onThreadCreated = options.onThreadCreated || (() => {});
+ this.socket = null;
+ this.currentUser = options.currentUser;
+ }
+
+ initialize(socketUrl) {
+ this.socket = new WebSocket(socketUrl);
+ this.setupSocketHandlers();
+ }
+
+ setupSocketHandlers() {
+ if (!this.socket) return;
+
+ this.socket.addEventListener('open', () => {
+ console.log('Collaboration system connected');
+ });
+
+ this.socket.addEventListener('message', (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ this.handleEvent(data);
+ } catch (error) {
+ console.error('Failed to parse collaboration event:', error);
+ }
+ });
+
+ this.socket.addEventListener('close', () => {
+ console.log('Collaboration system disconnected');
+ // Attempt to reconnect after delay
+ setTimeout(() => this.reconnect(), 5000);
+ });
+
+ this.socket.addEventListener('error', (error) => {
+ console.error('Collaboration system error:', error);
+ });
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case 'comment_added':
+ this.onCommentAdded(event.data);
+ break;
+ case 'comment_resolved':
+ this.onCommentResolved(event.data);
+ break;
+ case 'thread_created':
+ this.onThreadCreated(event.data);
+ break;
+ default:
+ console.warn('Unknown collaboration event:', event.type);
+ }
+ }
+
+ async createCommentThread(changeId, anchor, initialComment) {
+ try {
+ const response = await fetch('/vcs/comments/threads/create/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': this.getCsrfToken()
+ },
+ body: JSON.stringify({
+ change_id: changeId,
+ anchor: anchor,
+ initial_comment: initialComment
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to create comment thread');
+ }
+
+ const thread = await response.json();
+
+ // Notify other users through WebSocket
+ this.broadcastEvent({
+ type: 'thread_created',
+ data: {
+ thread_id: thread.id,
+ change_id: changeId,
+ anchor: anchor,
+ author: this.currentUser,
+ timestamp: new Date().toISOString()
+ }
+ });
+
+ return thread;
+ } catch (error) {
+ console.error('Error creating comment thread:', error);
+ throw error;
+ }
+ }
+
+ async addComment(threadId, content, parentId = null) {
+ try {
+ const response = await fetch('/vcs/comments/create/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': this.getCsrfToken()
+ },
+ body: JSON.stringify({
+ thread_id: threadId,
+ content: content,
+ parent_id: parentId
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to add comment');
+ }
+
+ const comment = await response.json();
+
+ // Notify other users through WebSocket
+ this.broadcastEvent({
+ type: 'comment_added',
+ data: {
+ comment_id: comment.id,
+ thread_id: threadId,
+ parent_id: parentId,
+ author: this.currentUser,
+ content: content,
+ timestamp: new Date().toISOString()
+ }
+ });
+
+ return comment;
+ } catch (error) {
+ console.error('Error adding comment:', error);
+ throw error;
+ }
+ }
+
+ async resolveThread(threadId) {
+ try {
+ const response = await fetch(`/vcs/comments/threads/${threadId}/resolve/`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': this.getCsrfToken()
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to resolve thread');
+ }
+
+ const result = await response.json();
+
+ // Notify other users through WebSocket
+ this.broadcastEvent({
+ type: 'comment_resolved',
+ data: {
+ thread_id: threadId,
+ resolver: this.currentUser,
+ timestamp: new Date().toISOString()
+ }
+ });
+
+ return result;
+ } catch (error) {
+ console.error('Error resolving thread:', error);
+ throw error;
+ }
+ }
+
+ broadcastEvent(event) {
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
+ this.socket.send(JSON.stringify(event));
+ }
+ }
+
+ reconnect() {
+ if (this.socket) {
+ try {
+ this.socket.close();
+ } catch (error) {
+ console.error('Error closing socket:', error);
+ }
+ }
+ this.initialize(this.socketUrl);
+ }
+
+ getCsrfToken() {
+ return document.querySelector('[name=csrfmiddlewaretoken]').value;
+ }
+
+ disconnect() {
+ if (this.socket) {
+ this.socket.close();
+ this.socket = null;
+ }
+ }
+}
+
+export default CollaborationSystem;
\ No newline at end of file
diff --git a/static/js/components/approval-panel.js b/static/js/components/approval-panel.js
new file mode 100644
index 00000000..d097a9e6
--- /dev/null
+++ b/static/js/components/approval-panel.js
@@ -0,0 +1,234 @@
+// Approval Panel Component
+
+class ApprovalPanel {
+ constructor(options = {}) {
+ this.container = null;
+ this.changeset = null;
+ this.currentUser = options.currentUser;
+ this.onApprove = options.onApprove || (() => {});
+ this.onReject = options.onReject || (() => {});
+ this.onSubmit = options.onSubmit || (() => {});
+ }
+
+ initialize(containerId) {
+ this.container = document.getElementById(containerId);
+ if (!this.container) {
+ throw new Error(`Container element with id "${containerId}" not found`);
+ }
+ }
+
+ setChangeset(changeset) {
+ this.changeset = changeset;
+ this.render();
+ }
+
+ render() {
+ if (!this.container || !this.changeset) return;
+
+ const approvalState = this.changeset.approval_state || [];
+ const currentStage = approvalState.find(s => s.status === 'pending') || approvalState[0];
+
+ this.container.innerHTML = `
+
+
+
+
+ ${this._renderStages(approvalState)}
+
+
+ ${currentStage && this.changeset.status === 'pending_approval' ?
+ this._renderApprovalActions(currentStage) : ''
+ }
+
+
+ ${this._renderHistory()}
+
+
+ `;
+
+ this.attachEventListeners();
+ }
+
+ _renderStatus() {
+ const statusMap = {
+ 'draft': { class: 'status-draft', text: 'Draft' },
+ 'pending_approval': { class: 'status-pending', text: 'Pending Approval' },
+ 'approved': { class: 'status-approved', text: 'Approved' },
+ 'rejected': { class: 'status-rejected', text: 'Rejected' },
+ 'applied': { class: 'status-applied', text: 'Applied' },
+ 'failed': { class: 'status-failed', text: 'Failed' },
+ 'reverted': { class: 'status-reverted', text: 'Reverted' }
+ };
+
+ const status = statusMap[this.changeset.status] || statusMap.draft;
+ return `
+
+ ${status.text}
+
+ `;
+ }
+
+ _renderStages(stages) {
+ return stages.map((stage, index) => `
+
+
+
+
+ ${stage.required_roles.map(role => `
+ ${role}
+ `).join('')}
+
+ ${stage.approvers.length > 0 ? `
+
+ ${stage.approvers.map(approver => this._renderApprover(approver)).join('')}
+
+ ` : ''}
+
+
+ `).join('');
+ }
+
+ _renderApprover(approver) {
+ const decisionClass = approver.decision === 'approve' ? 'approved' : 'rejected';
+ return `
+
+
+ ${approver.username}
+
+ ${new Date(approver.timestamp).toLocaleString()}
+
+
+
+ ${approver.decision === 'approve' ? 'Approved' : 'Rejected'}
+
+ ${approver.comment ? `
+
+ ` : ''}
+
+ `;
+ }
+
+ _renderApprovalActions(currentStage) {
+ if (!this.canUserApprove(currentStage)) {
+ return '';
+ }
+
+ return `
+
+
+
+
+ Reject Changes
+
+
+ Approve Changes
+
+
+
+ `;
+ }
+
+ _renderHistory() {
+ if (!this.changeset.approval_history?.length) {
+ return '';
+ }
+
+ return `
+
+ ${this.changeset.approval_history.map(entry => `
+
+
+
+ ${this._formatHistoryAction(entry.action)}
+
+ ${entry.comment ? `
+
+ ` : ''}
+
+ `).join('')}
+
+ `;
+ }
+
+ _formatStageStatus(status) {
+ const statusMap = {
+ 'pending': 'Pending',
+ 'approved': 'Approved',
+ 'rejected': 'Rejected'
+ };
+ return statusMap[status] || status;
+ }
+
+ _formatHistoryAction(action) {
+ const actionMap = {
+ 'submit': 'Submitted for approval',
+ 'approve': 'Approved changes',
+ 'reject': 'Rejected changes',
+ 'revert': 'Reverted approval'
+ };
+ return actionMap[action] || action;
+ }
+
+ canUserApprove(stage) {
+ if (!this.currentUser) return false;
+
+ // Check if user already approved
+ const alreadyApproved = stage.approvers.some(
+ a => a.user_id === this.currentUser.id
+ );
+ if (alreadyApproved) return false;
+
+ // Check if user has required role
+ return stage.required_roles.some(
+ role => this.currentUser.roles.includes(role)
+ );
+ }
+
+ async handleApprove() {
+ const commentEl = this.container.querySelector('.approval-comment');
+ const comment = commentEl ? commentEl.value.trim() : '';
+
+ try {
+ await this.onApprove(comment);
+ this.render();
+ } catch (error) {
+ console.error('Failed to approve:', error);
+ // Show error message
+ }
+ }
+
+ async handleReject() {
+ const commentEl = this.container.querySelector('.approval-comment');
+ const comment = commentEl ? commentEl.value.trim() : '';
+
+ try {
+ await this.onReject(comment);
+ this.render();
+ } catch (error) {
+ console.error('Failed to reject:', error);
+ // Show error message
+ }
+ }
+
+ attachEventListeners() {
+ // Add any additional event listeners if needed
+ }
+}
+
+export default ApprovalPanel;
\ No newline at end of file
diff --git a/static/js/components/diff-viewer.js b/static/js/components/diff-viewer.js
new file mode 100644
index 00000000..5115ece1
--- /dev/null
+++ b/static/js/components/diff-viewer.js
@@ -0,0 +1,274 @@
+// Enhanced Diff Viewer Component
+
+class DiffViewer {
+ constructor(options = {}) {
+ this.renderStrategy = options.renderStrategy || 'side-by-side';
+ this.syntaxHighlighters = new Map();
+ this.commentThreads = [];
+ this.container = null;
+ this.performance = {
+ startTime: null,
+ endTime: null
+ };
+ }
+
+ initialize(containerId) {
+ this.container = document.getElementById(containerId);
+ if (!this.container) {
+ throw new Error(`Container element with id "${containerId}" not found`);
+ }
+ this.setupSyntaxHighlighters();
+ }
+
+ setupSyntaxHighlighters() {
+ // Set up Prism.js or similar syntax highlighting library
+ this.syntaxHighlighters.set('text', this.plainTextHighlighter);
+ this.syntaxHighlighters.set('json', this.jsonHighlighter);
+ this.syntaxHighlighters.set('python', this.pythonHighlighter);
+ }
+
+ async render(diffData) {
+ this.performance.startTime = performance.now();
+
+ const { changes, metadata, navigation } = diffData;
+ const content = this.renderStrategy === 'side-by-side'
+ ? this.renderSideBySide(changes)
+ : this.renderInline(changes);
+
+ this.container.innerHTML = `
+
+
+
+ ${content}
+
+ ${this.renderNavigation(navigation)}
+
+ `;
+
+ this.attachEventListeners();
+ await this.highlightSyntax();
+
+ this.performance.endTime = performance.now();
+ this.updatePerformanceMetrics();
+ }
+
+ renderSideBySide(changes) {
+ return Object.entries(changes).map(([field, change]) => `
+
+
+
+
+
+ ${this.renderLineNumbers(change.metadata.line_numbers.old)}
+
+
${this.escapeHtml(change.old)}
+
+
+
+ ${this.renderLineNumbers(change.metadata.line_numbers.new)}
+
+
${this.escapeHtml(change.new)}
+
+
+
+ `).join('');
+ }
+
+ renderInline(changes) {
+ return Object.entries(changes).map(([field, change]) => `
+
+
+
+
+ ${this.renderLineNumbers(change.metadata.line_numbers.new)}
+
+
+ ${this.renderInlineDiff(change.old, change.new)}
+
+
+
+ `).join('');
+ }
+
+ renderMetadata(metadata) {
+ return `
+
+ ${new Date(metadata.timestamp).toLocaleString()}
+ ${metadata.user || 'Anonymous'}
+ ${this.formatChangeType(metadata.change_type)}
+ ${metadata.reason ? `${metadata.reason} ` : ''}
+
+ `;
+ }
+
+ renderControls() {
+ return `
+
+ Side by Side
+ Inline
+ Collapse All
+ Expand All
+
+ `;
+ }
+
+ renderNavigation(navigation) {
+ return `
+
+ ${navigation.prev_id ? `Previous ` : ''}
+ ${navigation.next_id ? `Next ` : ''}
+ Change ${navigation.current_position}
+
+ `;
+ }
+
+ renderLineNumbers(numbers) {
+ return numbers.map(num => `${num} `).join('');
+ }
+
+ renderInlineDiff(oldText, newText) {
+ // Simple inline diff implementation - could be enhanced with more sophisticated diff algorithm
+ const oldLines = oldText.split('\n');
+ const newLines = newText.split('\n');
+ const diffLines = [];
+
+ for (let i = 0; i < Math.max(oldLines.length, newLines.length); i++) {
+ if (oldLines[i] !== newLines[i]) {
+ if (oldLines[i]) {
+ diffLines.push(`${this.escapeHtml(oldLines[i])} `);
+ }
+ if (newLines[i]) {
+ diffLines.push(`${this.escapeHtml(newLines[i])} `);
+ }
+ } else if (oldLines[i]) {
+ diffLines.push(this.escapeHtml(oldLines[i]));
+ }
+ }
+
+ return diffLines.join('\n');
+ }
+
+ attachEventListeners() {
+ // View mode switching
+ this.container.querySelectorAll('.btn-view-mode').forEach(btn => {
+ btn.addEventListener('click', () => {
+ this.renderStrategy = btn.dataset.mode;
+ this.render(this.currentDiffData);
+ });
+ });
+
+ // Collapse/Expand functionality
+ this.container.querySelectorAll('.diff-section').forEach(section => {
+ section.querySelector('.diff-field-header').addEventListener('click', () => {
+ section.classList.toggle('collapsed');
+ });
+ });
+
+ // Navigation
+ this.container.querySelectorAll('.diff-navigation button').forEach(btn => {
+ btn.addEventListener('click', () => {
+ this.navigateToChange(btn.dataset.id);
+ });
+ });
+ }
+
+ async highlightSyntax() {
+ const codeBlocks = this.container.querySelectorAll('code[class^="language-"]');
+ for (const block of codeBlocks) {
+ const syntax = block.className.replace('language-', '');
+ const highlighter = this.syntaxHighlighters.get(syntax);
+ if (highlighter) {
+ await highlighter(block);
+ }
+ }
+ }
+
+ // Syntax highlighters
+ async plainTextHighlighter(element) {
+ // No highlighting needed for plain text
+ return element;
+ }
+
+ async jsonHighlighter(element) {
+ try {
+ const content = element.textContent;
+ const parsed = JSON.parse(content);
+ element.textContent = JSON.stringify(parsed, null, 2);
+ // Apply JSON syntax highlighting classes
+ element.innerHTML = element.innerHTML.replace(
+ /"([^"]+)":/g,
+ '"$1": '
+ );
+ } catch (e) {
+ console.warn('JSON parsing failed:', e);
+ }
+ return element;
+ }
+
+ async pythonHighlighter(element) {
+ // Basic Python syntax highlighting
+ element.innerHTML = element.innerHTML
+ .replace(/(def|class|import|from|return|if|else|try|except)\b/g, '$1 ')
+ .replace(/(["'])(.*?)\1/g, '$1$2$1 ')
+ .replace(/#.*/g, '');
+ return element;
+ }
+
+ updatePerformanceMetrics() {
+ const renderTime = this.performance.endTime - this.performance.startTime;
+ if (renderTime > 200) { // Performance budget: 200ms
+ console.warn(`Diff render time (${renderTime}ms) exceeded performance budget`);
+ }
+ }
+
+ escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ formatChangeType(type) {
+ const types = {
+ 'C': 'Changed',
+ 'D': 'Deleted',
+ 'A': 'Added'
+ };
+ return types[type] || type;
+ }
+
+ addCommentThread(anchor, thread) {
+ this.commentThreads.push({ anchor, thread });
+ this.renderCommentThreads();
+ }
+
+ renderCommentThreads() {
+ this.commentThreads.forEach(({ anchor, thread }) => {
+ const element = this.container.querySelector(`[data-anchor="${anchor}"]`);
+ if (element) {
+ const threadElement = document.createElement('div');
+ threadElement.className = 'comment-thread';
+ threadElement.innerHTML = thread.map(comment => `
+
+ `).join('');
+ element.appendChild(threadElement);
+ }
+ });
+ }
+}
+
+export default DiffViewer;
\ No newline at end of file
diff --git a/static/js/components/inline-comment-panel.js b/static/js/components/inline-comment-panel.js
new file mode 100644
index 00000000..69d33d70
--- /dev/null
+++ b/static/js/components/inline-comment-panel.js
@@ -0,0 +1,285 @@
+// Inline Comment Panel Component
+
+class InlineCommentPanel {
+ constructor(options = {}) {
+ this.container = null;
+ this.thread = null;
+ this.canResolve = options.canResolve || false;
+ this.onReply = options.onReply || (() => {});
+ this.onResolve = options.onResolve || (() => {});
+ this.currentUser = options.currentUser;
+ }
+
+ initialize(containerId) {
+ this.container = document.getElementById(containerId);
+ if (!this.container) {
+ throw new Error(`Container element with id "${containerId}" not found`);
+ }
+ }
+
+ setThread(thread) {
+ this.thread = thread;
+ this.render();
+ }
+
+ render() {
+ if (!this.container || !this.thread) return;
+
+ this.container.innerHTML = `
+
+ `;
+
+ this.attachEventListeners();
+ }
+
+ renderComments(comments) {
+ return comments.map(comment => `
+
+ `).join('');
+ }
+
+ renderCommentActions(comment) {
+ if (this.thread.is_resolved) return '';
+
+ return `
+
+ `;
+ }
+
+ formatCommentContent(content) {
+ // Replace @mentions with styled spans
+ content = content.replace(/@(\w+)/g, '@$1 ');
+
+ // Convert URLs to links
+ content = content.replace(
+ /(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g,
+ '$1 '
+ );
+
+ return content;
+ }
+
+ formatAnchor(anchor) {
+ const start = anchor.line_start;
+ const end = anchor.line_end;
+ const file = anchor.file_path.split('/').pop();
+
+ return end > start ?
+ `${file}:${start}-${end}` :
+ `${file}:${start}`;
+ }
+
+ formatDate(dateString) {
+ const date = new Date(dateString);
+ return date.toLocaleString();
+ }
+
+ attachEventListeners() {
+ const replyInput = this.container.querySelector('.reply-input');
+ if (replyInput) {
+ replyInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
+ this.submitReply();
+ }
+ });
+ }
+ }
+
+ async submitReply() {
+ const input = this.container.querySelector('.reply-input');
+ const content = input.value.trim();
+
+ if (!content) return;
+
+ try {
+ await this.onReply(content);
+ input.value = '';
+ } catch (error) {
+ console.error('Failed to submit reply:', error);
+ // Show error message to user
+ }
+ }
+
+ async resolveThread() {
+ try {
+ await this.onResolve();
+ this.render();
+ } catch (error) {
+ console.error('Failed to resolve thread:', error);
+ // Show error message to user
+ }
+ }
+
+ showReplyForm(commentId) {
+ const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
+ if (!comment) return;
+
+ const replyForm = document.createElement('div');
+ replyForm.className = 'reply-form nested';
+ replyForm.innerHTML = `
+
+
+
+ Cancel
+
+
+ Reply
+
+
+ `;
+
+ comment.appendChild(replyForm);
+ replyForm.querySelector('.reply-input').focus();
+ }
+
+ hideReplyForm(commentId) {
+ const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
+ if (!comment) return;
+
+ const replyForm = comment.querySelector('.reply-form');
+ if (replyForm) {
+ replyForm.remove();
+ }
+ }
+
+ async submitNestedReply(parentId) {
+ const comment = this.container.querySelector(`[data-comment-id="${parentId}"]`);
+ if (!comment) return;
+
+ const input = comment.querySelector('.reply-input');
+ const content = input.value.trim();
+
+ if (!content) return;
+
+ try {
+ await this.onReply(content, parentId);
+ this.hideReplyForm(parentId);
+ } catch (error) {
+ console.error('Failed to submit reply:', error);
+ // Show error message to user
+ }
+ }
+
+ editComment(commentId) {
+ const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
+ if (!comment) return;
+
+ const contentDiv = comment.querySelector('.comment-content');
+ const content = contentDiv.textContent;
+
+ contentDiv.innerHTML = `
+ ${content}
+
+
+ Cancel
+
+
+ Save
+
+
+ `;
+ }
+
+ cancelEdit(commentId) {
+ // Refresh the entire thread to restore original content
+ this.render();
+ }
+
+ async saveEdit(commentId) {
+ const comment = this.container.querySelector(`[data-comment-id="${commentId}"]`);
+ if (!comment) return;
+
+ const input = comment.querySelector('.edit-input');
+ const content = input.value.trim();
+
+ if (!content) return;
+
+ try {
+ // Emit edit event
+ const event = new CustomEvent('comment-edited', {
+ detail: { commentId, content }
+ });
+ this.container.dispatchEvent(event);
+
+ // Refresh the thread
+ this.render();
+ } catch (error) {
+ console.error('Failed to edit comment:', error);
+ // Show error message to user
+ }
+ }
+}
+
+export default InlineCommentPanel;
\ No newline at end of file
diff --git a/static/js/components/version-comparison.js b/static/js/components/version-comparison.js
new file mode 100644
index 00000000..fc480a95
--- /dev/null
+++ b/static/js/components/version-comparison.js
@@ -0,0 +1,314 @@
+// Version Comparison Component
+
+class VersionComparison {
+ constructor(options = {}) {
+ this.container = null;
+ this.versions = new Map();
+ this.selectedVersions = new Set();
+ this.maxSelections = options.maxSelections || 3;
+ this.onCompare = options.onCompare || (() => {});
+ this.onRollback = options.onRollback || (() => {});
+ this.timeline = null;
+ }
+
+ initialize(containerId) {
+ this.container = document.getElementById(containerId);
+ if (!this.container) {
+ throw new Error(`Container element with id "${containerId}" not found`);
+ }
+ this._initializeTimeline();
+ }
+
+ setVersions(versions) {
+ this.versions = new Map(versions.map(v => [v.name, v]));
+ this._updateTimeline();
+ this.render();
+ }
+
+ _initializeTimeline() {
+ this.timeline = document.createElement('div');
+ this.timeline.className = 'version-timeline';
+ this.container.appendChild(this.timeline);
+ }
+
+ _updateTimeline() {
+ const sortedVersions = Array.from(this.versions.values())
+ .sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
+
+ this.timeline.innerHTML = `
+
+ ${this._renderTimelineDots(sortedVersions)}
+
+
+ ${this._renderTimelineLabels(sortedVersions)}
+
+ `;
+ }
+
+ _renderTimelineDots(versions) {
+ return versions.map(version => `
+
+
+ ${this._renderImpactIndicator(version)}
+
+ `).join('');
+ }
+
+ _renderImpactIndicator(version) {
+ const impact = version.comparison_metadata.impact_score || 0;
+ const size = Math.max(8, Math.min(24, impact * 24)); // 8-24px based on impact
+
+ return `
+
+
+ `;
+ }
+
+ _renderTimelineLabels(versions) {
+ return versions.map(version => `
+
+
${version.name}
+
+ ${new Date(version.created_at).toLocaleDateString()}
+
+
+ `).join('');
+ }
+
+ render() {
+ if (!this.container) return;
+
+ const selectedVersionsArray = Array.from(this.selectedVersions);
+
+ this.container.innerHTML = `
+
+ `;
+
+ this.attachEventListeners();
+ }
+
+ _renderSelectedVersions(selectedVersions) {
+ return selectedVersions.map((version, index) => `
+
+ Version ${index + 1}:
+ ${version}
+
+ ×
+
+
+ `).join('');
+ }
+
+ _renderActionButtons(selectedVersions) {
+ const canCompare = selectedVersions.length >= 2;
+ const canRollback = selectedVersions.length === 1;
+
+ return `
+ ${canCompare ? `
+
+ Compare Versions
+
+ ` : ''}
+ ${canRollback ? `
+
+ Rollback to Version
+
+ ` : ''}
+ `;
+ }
+
+ _renderComparisonContent(selectedVersions) {
+ if (selectedVersions.length < 2) {
+ return `
+
+ Select at least two versions to compare
+
+ `;
+ }
+
+ return `
+
+
+ Computing differences...
+
+
+ `;
+ }
+
+ _toggleVersionSelection(versionName) {
+ if (this.selectedVersions.has(versionName)) {
+ this.selectedVersions.delete(versionName);
+ } else if (this.selectedVersions.size < this.maxSelections) {
+ this.selectedVersions.add(versionName);
+ } else {
+ // Show max selections warning
+ this._showWarning(`Maximum ${this.maxSelections} versions can be compared`);
+ return;
+ }
+ this.render();
+ }
+
+ _removeVersion(versionName) {
+ this.selectedVersions.delete(versionName);
+ this.render();
+ }
+
+ async _handleCompare() {
+ const selectedVersions = Array.from(this.selectedVersions);
+ if (selectedVersions.length < 2) return;
+
+ try {
+ const results = await this.onCompare(selectedVersions);
+ this._renderComparisonResults(results);
+ } catch (error) {
+ console.error('Comparison failed:', error);
+ this._showError('Failed to compare versions');
+ }
+ }
+
+ async _handleRollback() {
+ const selectedVersion = Array.from(this.selectedVersions)[0];
+ if (!selectedVersion) return;
+
+ try {
+ await this.onRollback(selectedVersion);
+ // Handle successful rollback
+ } catch (error) {
+ console.error('Rollback failed:', error);
+ this._showError('Failed to rollback version');
+ }
+ }
+
+ _renderComparisonResults(results) {
+ const resultsContainer = this.container.querySelector('.comparison-results');
+ if (!resultsContainer) return;
+
+ resultsContainer.innerHTML = `
+
+ ${results.map(diff => this._renderDiffSection(diff)).join('')}
+
+ `;
+ }
+
+ _renderDiffSection(diff) {
+ return `
+
+
+
+ ${this._renderChanges(diff.changes)}
+
+
+ `;
+ }
+
+ _renderChanges(changes) {
+ return changes.map(change => `
+
+
+
+
+
+
${this._escapeHtml(change.old_value)}
+
+
+
+
${this._escapeHtml(change.new_value)}
+
+
+
+ `).join('');
+ }
+
+ _showWarning(message) {
+ const warning = document.createElement('div');
+ warning.className = 'comparison-warning';
+ warning.textContent = message;
+ this.container.appendChild(warning);
+ setTimeout(() => warning.remove(), 3000);
+ }
+
+ _showError(message) {
+ const error = document.createElement('div');
+ error.className = 'comparison-error';
+ error.textContent = message;
+ this.container.appendChild(error);
+ setTimeout(() => error.remove(), 3000);
+ }
+
+ _escapeHtml(text) {
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
+
+ attachEventListeners() {
+ // Timeline scroll handling
+ const timeline = this.container.querySelector('.version-timeline');
+ if (timeline) {
+ let isDown = false;
+ let startX;
+ let scrollLeft;
+
+ timeline.addEventListener('mousedown', (e) => {
+ isDown = true;
+ timeline.classList.add('active');
+ startX = e.pageX - timeline.offsetLeft;
+ scrollLeft = timeline.scrollLeft;
+ });
+
+ timeline.addEventListener('mouseleave', () => {
+ isDown = false;
+ timeline.classList.remove('active');
+ });
+
+ timeline.addEventListener('mouseup', () => {
+ isDown = false;
+ timeline.classList.remove('active');
+ });
+
+ timeline.addEventListener('mousemove', (e) => {
+ if (!isDown) return;
+ e.preventDefault();
+ const x = e.pageX - timeline.offsetLeft;
+ const walk = (x - startX) * 2;
+ timeline.scrollLeft = scrollLeft - walk;
+ });
+ }
+ }
+}
+
+export default VersionComparison;
\ No newline at end of file
diff --git a/static/js/components/virtual-scroller.js b/static/js/components/virtual-scroller.js
new file mode 100644
index 00000000..b189eea0
--- /dev/null
+++ b/static/js/components/virtual-scroller.js
@@ -0,0 +1,190 @@
+// Virtual Scroller Component
+// Implements efficient scrolling for large lists by only rendering visible items
+
+class VirtualScroller {
+ constructor(options) {
+ this.container = options.container;
+ this.itemHeight = options.itemHeight;
+ this.bufferSize = options.bufferSize || 5;
+ this.renderItem = options.renderItem;
+ this.items = [];
+ this.scrollTop = 0;
+ this.visibleItems = new Map();
+
+ this.observer = new IntersectionObserver(
+ this._handleIntersection.bind(this),
+ { threshold: 0.1 }
+ );
+
+ this._setupContainer();
+ this._bindEvents();
+ }
+
+ _setupContainer() {
+ if (!this.container) {
+ throw new Error('Container element is required');
+ }
+
+ this.container.style.position = 'relative';
+ this.container.style.height = '600px'; // Default height
+ this.container.style.overflowY = 'auto';
+
+ // Create spacer element to maintain scroll height
+ this.spacer = document.createElement('div');
+ this.spacer.style.position = 'absolute';
+ this.spacer.style.top = '0';
+ this.spacer.style.left = '0';
+ this.spacer.style.width = '1px';
+ this.container.appendChild(this.spacer);
+ }
+
+ _bindEvents() {
+ this.container.addEventListener(
+ 'scroll',
+ this._debounce(this._handleScroll.bind(this), 16)
+ );
+
+ // Handle container resize
+ if (window.ResizeObserver) {
+ const resizeObserver = new ResizeObserver(this._debounce(() => {
+ this._render();
+ }, 16));
+ resizeObserver.observe(this.container);
+ }
+ }
+
+ setItems(items) {
+ this.items = items;
+ this.spacer.style.height = `${items.length * this.itemHeight}px`;
+ this._render();
+ }
+
+ _handleScroll() {
+ this.scrollTop = this.container.scrollTop;
+ this._render();
+ }
+
+ _handleIntersection(entries) {
+ entries.forEach(entry => {
+ const itemId = entry.target.dataset.itemId;
+ if (!entry.isIntersecting) {
+ this.visibleItems.delete(itemId);
+ entry.target.remove();
+ }
+ });
+ }
+
+ _render() {
+ const visibleRange = this._getVisibleRange();
+ const itemsToRender = new Set();
+
+ // Calculate which items should be visible
+ for (let i = visibleRange.start; i <= visibleRange.end; i++) {
+ if (i >= 0 && i < this.items.length) {
+ itemsToRender.add(i);
+ }
+ }
+
+ // Remove items that are no longer visible
+ for (const [itemId] of this.visibleItems) {
+ const index = parseInt(itemId);
+ if (!itemsToRender.has(index)) {
+ const element = this.container.querySelector(`[data-item-id="${itemId}"]`);
+ if (element) {
+ this.observer.unobserve(element);
+ element.remove();
+ }
+ this.visibleItems.delete(itemId);
+ }
+ }
+
+ // Add new visible items
+ for (const index of itemsToRender) {
+ if (!this.visibleItems.has(index.toString())) {
+ this._renderItem(index);
+ }
+ }
+
+ // Update performance metrics
+ this._updateMetrics(itemsToRender.size);
+ }
+
+ _renderItem(index) {
+ const item = this.items[index];
+ const element = document.createElement('div');
+
+ element.style.position = 'absolute';
+ element.style.top = `${index * this.itemHeight}px`;
+ element.style.left = '0';
+ element.style.width = '100%';
+ element.dataset.itemId = index.toString();
+
+ // Render content
+ element.innerHTML = this.renderItem(item);
+
+ // Add to container and observe
+ this.container.appendChild(element);
+ this.observer.observe(element);
+ this.visibleItems.set(index.toString(), element);
+
+ // Adjust actual height if needed
+ const actualHeight = element.offsetHeight;
+ if (actualHeight !== this.itemHeight) {
+ element.style.height = `${this.itemHeight}px`;
+ }
+ }
+
+ _getVisibleRange() {
+ const start = Math.floor(this.scrollTop / this.itemHeight) - this.bufferSize;
+ const visibleCount = Math.ceil(this.container.clientHeight / this.itemHeight);
+ const end = start + visibleCount + 2 * this.bufferSize;
+ return { start, end };
+ }
+
+ _updateMetrics(visibleCount) {
+ const metrics = {
+ totalItems: this.items.length,
+ visibleItems: visibleCount,
+ scrollPosition: this.scrollTop,
+ containerHeight: this.container.clientHeight,
+ renderTime: performance.now() // You can use this with the previous render time
+ };
+
+ // Dispatch metrics event
+ this.container.dispatchEvent(new CustomEvent('virtualScroller:metrics', {
+ detail: metrics
+ }));
+ }
+
+ _debounce(func, wait) {
+ let timeout;
+ return function executedFunction(...args) {
+ const later = () => {
+ clearTimeout(timeout);
+ func(...args);
+ };
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ };
+ }
+
+ // Public methods
+ scrollToIndex(index) {
+ if (index >= 0 && index < this.items.length) {
+ this.container.scrollTop = index * this.itemHeight;
+ }
+ }
+
+ refresh() {
+ this._render();
+ }
+
+ destroy() {
+ this.observer.disconnect();
+ this.container.innerHTML = '';
+ this.items = [];
+ this.visibleItems.clear();
+ }
+}
+
+export default VirtualScroller;
\ No newline at end of file
diff --git a/static/js/version-control.js b/static/js/version-control.js
index 5c8fc0d7..9554e96a 100644
--- a/static/js/version-control.js
+++ b/static/js/version-control.js
@@ -124,6 +124,289 @@ class VersionControl {
.then(response => response.json());
}
+ // Comment operations
+ async createComment(threadId, content, parentId = null) {
+ try {
+ const response = await fetch('/vcs/comments/create/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': this.getCsrfToken()
+ },
+ body: JSON.stringify({
+ thread_id: threadId,
+ content: content,
+ parent_id: parentId
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to create comment');
+ }
+
+ const comment = await response.json();
+ return comment;
+ } catch (error) {
+ this.showError('Error creating comment: ' + error.message);
+ throw error;
+ }
+ }
+
+ async createCommentThread(changeId, anchor, initialComment) {
+ try {
+ const response = await fetch('/vcs/comments/threads/create/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': this.getCsrfToken()
+ },
+ body: JSON.stringify({
+ change_id: changeId,
+ anchor: anchor,
+ initial_comment: initialComment
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to create comment thread');
+ }
+
+ const thread = await response.json();
+ return thread;
+ } catch (error) {
+ this.showError('Error creating comment thread: ' + error.message);
+ throw error;
+ }
+ }
+
+ async resolveThread(threadId) {
+ try {
+ const response = await fetch(`/vcs/comments/threads/${threadId}/resolve/`, {
+ method: 'POST',
+ headers: {
+ 'X-CSRFToken': this.getCsrfToken()
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to resolve thread');
+ }
+
+ return await response.json();
+ } catch (error) {
+ this.showError('Error resolving thread: ' + error.message);
+ throw error;
+ }
+ }
+
+ async editComment(commentId, content) {
+ try {
+ const response = await fetch(`/vcs/comments/${commentId}/`, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': this.getCsrfToken()
+ },
+ body: JSON.stringify({
+ content: content
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to edit comment');
+ }
+
+ return await response.json();
+ } catch (error) {
+ this.showError('Error editing comment: ' + error.message);
+ throw error;
+ }
+ }
+
+ async getThreadComments(threadId) {
+ try {
+ const response = await fetch(`/vcs/comments/threads/${threadId}/`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch thread comments');
+ }
+ return await response.json();
+ } catch (error) {
+ this.showError('Error fetching comments: ' + error.message);
+ throw error;
+ }
+ }
+
+ initializeCommentPanel(containerId, options = {}) {
+ const panel = new InlineCommentPanel({
+ ...options,
+ onReply: async (content, parentId) => {
+ const comment = await this.createComment(
+ options.threadId,
+ content,
+ parentId
+ );
+ const thread = await this.getThreadComments(options.threadId);
+ panel.setThread(thread);
+ },
+ onResolve: async () => {
+ await this.resolveThread(options.threadId);
+ const thread = await this.getThreadComments(options.threadId);
+ panel.setThread(thread);
+ }
+ });
+ panel.initialize(containerId);
+ return panel;
+ }
+
+ // Branch locking operations
+ async acquireLock(branchName, duration = 48, reason = "") {
+ try {
+ const response = await fetch('/vcs/branches/lock/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': this.getCsrfToken()
+ },
+ body: JSON.stringify({
+ branch: branchName,
+ duration: duration,
+ reason: reason
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to acquire lock');
+ }
+
+ const result = await response.json();
+ if (result.success) {
+ this.refreshLockStatus(branchName);
+ return true;
+ }
+ return false;
+ } catch (error) {
+ this.showError('Error acquiring lock: ' + error.message);
+ return false;
+ }
+ }
+
+ async releaseLock(branchName, force = false) {
+ try {
+ const response = await fetch('/vcs/branches/unlock/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': this.getCsrfToken()
+ },
+ body: JSON.stringify({
+ branch: branchName,
+ force: force
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to release lock');
+ }
+
+ const result = await response.json();
+ if (result.success) {
+ this.refreshLockStatus(branchName);
+ return true;
+ }
+ return false;
+ } catch (error) {
+ this.showError('Error releasing lock: ' + error.message);
+ return false;
+ }
+ }
+
+ async getLockStatus(branchName) {
+ try {
+ const response = await fetch(`/vcs/branches/${encodeURIComponent(branchName)}/lock-status/`);
+ if (!response.ok) {
+ throw new Error('Failed to get lock status');
+ }
+ return await response.json();
+ } catch (error) {
+ this.showError('Error getting lock status: ' + error.message);
+ return null;
+ }
+ }
+
+ async getLockHistory(branchName, limit = 10) {
+ try {
+ const response = await fetch(
+ `/vcs/branches/${encodeURIComponent(branchName)}/lock-history/?limit=${limit}`
+ );
+ if (!response.ok) {
+ throw new Error('Failed to get lock history');
+ }
+ return await response.json();
+ } catch (error) {
+ this.showError('Error getting lock history: ' + error.message);
+ return [];
+ }
+ }
+
+ async refreshLockStatus(branchName) {
+ const lockStatus = await this.getLockStatus(branchName);
+ if (!lockStatus) return;
+
+ const statusElement = document.querySelector(`[data-branch="${branchName}"] .lock-status`);
+ if (!statusElement) return;
+
+ if (lockStatus.locked) {
+ const expiryDate = new Date(lockStatus.expires);
+ statusElement.className = 'lock-status locked';
+ statusElement.innerHTML = `
+
+
+
+
+ ${lockStatus.user}
+ Expires: ${expiryDate.toLocaleString()}
+ ${lockStatus.reason ? `${lockStatus.reason} ` : ''}
+
+ `;
+ } else {
+ statusElement.className = 'lock-status unlocked';
+ statusElement.innerHTML = `
+
+
+
+ Unlocked
+ `;
+ }
+
+ // Update lock controls
+ this.updateLockControls(branchName, lockStatus);
+ }
+
+ async updateLockControls(branchName, lockStatus) {
+ const controlsElement = document.querySelector(`[data-branch="${branchName}"] .lock-controls`);
+ if (!controlsElement) return;
+
+ if (lockStatus.locked) {
+ controlsElement.innerHTML = `
+
+
+
+
+ Unlock Branch
+
+ `;
+ } else {
+ controlsElement.innerHTML = `
+
+
+
+
+ Lock Branch
+
+ `;
+ }
+ }
+
// Utility functions
getCsrfToken() {
return document.querySelector('[name=csrfmiddlewaretoken]').value;
@@ -149,7 +432,105 @@ class VersionControl {
}
}
+// Import DiffViewer component
+import DiffViewer from './components/diff-viewer.js';
+
// Initialize version control
document.addEventListener('DOMContentLoaded', () => {
window.versionControl = new VersionControl();
-});
\ No newline at end of file
+
+ // Initialize DiffViewer if diff container exists
+ const diffContainer = document.getElementById('diff-container');
+ if (diffContainer) {
+ window.diffViewer = new DiffViewer({
+ renderStrategy: 'side-by-side'
+ });
+ diffViewer.initialize('diff-container');
+ }
+});
+
+// Add to VersionControl class constructor
+class VersionControl {
+ constructor() {
+ this.setupEventListeners();
+ this.diffViewer = null;
+ if (document.getElementById('diff-container')) {
+ this.diffViewer = new DiffViewer({
+ renderStrategy: 'side-by-side'
+ });
+ this.diffViewer.initialize('diff-container');
+ }
+ }
+
+ // Add getDiff method to VersionControl class
+ async getDiff(version1, version2) {
+ const url = `/vcs/diff/?v1=${encodeURIComponent(version1)}&v2=${encodeURIComponent(version2)}`;
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error('Failed to fetch diff');
+ }
+ const diffData = await response.json();
+
+ if (this.diffViewer) {
+ await this.diffViewer.render(diffData);
+ }
+
+ return diffData;
+ } catch (error) {
+ this.showError('Error loading diff: ' + error.message);
+ throw error;
+ }
+ }
+
+ // Add viewChanges method to VersionControl class
+ async viewChanges(changeId) {
+ try {
+ const response = await fetch(`/vcs/changes/${changeId}/`);
+ if (!response.ok) {
+ throw new Error('Failed to fetch changes');
+ }
+ const changeData = await response.json();
+
+ if (this.diffViewer) {
+ await this.diffViewer.render(changeData);
+ } else {
+ this.showError('Diff viewer not initialized');
+ }
+ } catch (error) {
+ this.showError('Error loading changes: ' + error.message);
+ throw error;
+ }
+ }
+
+ // Add addComment method to VersionControl class
+ async addComment(changeId, anchor, content) {
+ try {
+ const response = await fetch('/vcs/comments/add/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': this.getCsrfToken()
+ },
+ body: JSON.stringify({
+ change_id: changeId,
+ anchor: anchor,
+ content: content
+ })
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to add comment');
+ }
+
+ const comment = await response.json();
+ if (this.diffViewer) {
+ this.diffViewer.addCommentThread(anchor, [comment]);
+ }
+
+ return comment;
+ } catch (error) {
+ this.showError('Error adding comment: ' + error.message);
+ throw error;
+ }
+ }
\ No newline at end of file
Preview:
+