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 %} +
+
+

Approval Status

+
+ Changeset #{{ changeset.pk }} + {{ changeset.status|title }} +
+
+ + {% 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 %} +
    + {% for approver in current_stage.approvers %} +
  • +
    + {{ approver.user }} + {{ approver.timestamp|date:"Y-m-d H:i" }} +
    + {% if approver.comment %} +
    + {{ approver.comment }} +
    + {% endif %} +
  • + {% endfor %} +
+ {% else %} +

No approvals yet

+ {% endif %} +
+ + {% if can_approve %} +
+
+ {% csrf_token %} +
+ + +
+ {% if current_stage.id %} + + {% endif %} +
+ + +
+
+
+ {% endif %} +
+ {% endif %} + +
+

Approval History

+ {% if changeset.approval_history %} +
+ {% for entry in changeset.approval_history %} +
+
+ {{ entry.stage_name }} + +
+
+ {{ entry.user }} + {{ entry.action|title }} + {% if entry.comment %} +
+ {{ entry.comment }} +
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +

No approval history yet

+ {% endif %} +
+ + {% if pending_approvers %} +
+

Waiting for Approval From:

+ +
+ {% 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 %} +
+
+ {{ notification.type }} + {{ notification.timestamp|timesince }} ago +
+
+ {% if notification.type == 'approval' %} + {{ notification.user }} + {{ notification.action }} the changes + {% if notification.stage %} + in stage {{ notification.stage }} + {% endif %} + {% elif notification.type == 'comment' %} + {{ notification.user }} + commented on the changes + {% elif notification.type == 'stage_change' %} + Moved to stage {{ notification.stage }} + {% endif %} + + {% if notification.comment %} +
+ "{{ notification.comment }}" +
+ {% endif %} +
+ {% if notification.actions %} +
+ {% for action in notification.actions %} + + {% 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 %} +
    + {% for approver in current_stage.approvers %} +
  • +
    + {{ approver.user }} + {{ approver.timestamp|date:"Y-m-d H:i" }} +
    + {% if approver.comment %} +
    + {{ approver.comment }} +
    + {% endif %} +
  • + {% endfor %} +
+ {% else %} +

No approvals yet

+ {% endif %} +
+ + {% if can_approve %} +
+
+ {% csrf_token %} +
+ + +
+
+ {% if current_stage.id %} + + {% endif %} +
+ + +
+
+
+ {% endif %} + + {% if messages %} +
+ {% for message in messages %} + + {% 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 %} +
+
Preview:
+
{{ 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 %} +
+
+ {{ reply.author }} + {{ reply.created_at|date:"Y-m-d H:i" }} +
+
+ {{ reply.content }} +
+ {% if user.has_perm 'history_tracking.add_comment' %} +
+ +
+
+ {% endif %} +
+ {% 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 %} +
+ {% for comment in comments %} +
+
+ {{ comment.author }} + {{ comment.created_at|date:"Y-m-d H:i" }} +
+
{{ comment.content }}
+ {% if user.has_perm 'history_tracking.add_comment' %} +
+ +
+
+ {% endif %} + {% if comment.replies.exists %} +
+
Loading replies...
+
+ {% endif %} +
+ {% endfor %} +
+{% else %} +

No comments yet

+{% 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 @@ +
+
+ {% csrf_token %} + + +
+ +
+
+
+ + +
+
+
\ 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 %} +
+
+

Version Comparison

+ + {# Version Selection Form with HTMX #} +
+
+
+ + +
+
+ + +
+
+
Loading comparison...
+
+
+ + {% 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 %} +
+
+

{{ change.field }}

+ {{ change.type }} +
+ +
+
+
Previous Version
+
{{ change.old }}
+
+
+
New Version
+
{{ change.new }}
+
+
+ + {% if user.has_perm 'history_tracking.add_comment' %} +
+
Loading comments...
+
+ +
+
+ {% csrf_token %} + + +
+ +
+
+ {% 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 = ` +
+
+

Change Approval

+ ${this._renderStatus()} +
+ +
+ ${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.name} + ${ + this._formatStageStatus(stage.status) + } +
+
+
+ ${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 ? ` +
${approver.comment}
+ ` : ''} +
+ `; + } + + _renderApprovalActions(currentStage) { + if (!this.canUserApprove(currentStage)) { + return ''; + } + + return ` +
+ +
+ + +
+
+ `; + } + + _renderHistory() { + if (!this.changeset.approval_history?.length) { + return ''; + } + + return ` +
+ ${this.changeset.approval_history.map(entry => ` +
+
+ ${entry.username} + +
+
+ ${this._formatHistoryAction(entry.action)} +
+ ${entry.comment ? ` +
${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 = ` +
+
+ ${this.renderMetadata(metadata)} + ${this.renderControls()} +
+
+ ${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]) => ` +
+
+ ${field} + ${change.syntax_type} +
+
+
+
+ ${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]) => ` +
+
+ ${field} + ${change.syntax_type} +
+
+
+ ${this.renderLineNumbers(change.metadata.line_numbers.new)} +
+

+                        ${this.renderInlineDiff(change.old, change.new)}
+                    
+
+
+ `).join(''); + } + + renderMetadata(metadata) { + return ` + + `; + } + + renderControls() { + return ` +
+ + + + +
+ `; + } + + renderNavigation(navigation) { + return ` +
+ ${navigation.prev_id ? `` : ''} + ${navigation.next_id ? `` : ''} + 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 => ` +
+
+ ${comment.author} + ${new Date(comment.date).toLocaleString()} +
+
${comment.content}
+
+ `).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.formatAnchor(this.thread.anchor)} + ${this.thread.is_resolved ? + ` + + + + Resolved + ` + : '' + } +
+ ${this.canResolve && !this.thread.is_resolved ? + `` + : '' + } +
+
+ ${this.renderComments(this.thread.comments)} +
+ ${!this.thread.is_resolved ? + `
+ +
+ +
+
` + : '' + } +
+ `; + + this.attachEventListeners(); + } + + renderComments(comments) { + return comments.map(comment => ` +
+
+ ${comment.author.username} + ${comment.author.username} + ${this.formatDate(comment.created_at)} +
+
+ ${this.formatCommentContent(comment.content)} +
+ ${this.renderCommentActions(comment)} + ${comment.replies ? + `
+ ${this.renderComments(comment.replies)} +
` + : '' + } +
+ `).join(''); + } + + renderCommentActions(comment) { + if (this.thread.is_resolved) return ''; + + return ` +
+ + ${comment.author.id === this.currentUser.id ? + `` + : '' + } +
+ `; + } + + 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 = ` + +
+ + +
+ `; + + 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 = ` + +
+ + +
+ `; + } + + 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 = ` +
+
+

Version Comparison

+
+ ${this._renderSelectedVersions(selectedVersionsArray)} +
+
+ ${this._renderActionButtons(selectedVersionsArray)} +
+
+ + ${this.timeline.outerHTML} + +
+ ${this._renderComparisonContent(selectedVersionsArray)} +
+
+ `; + + 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 ? ` + + ` : ''} + ${canRollback ? ` + + ` : ''} + `; + } + + _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 ` +
+
+

Changes: ${diff.version1} → ${diff.version2}

+
+ + Computed in ${diff.computation_time.toFixed(2)}s + + + Impact Score: ${Math.round(diff.impact_score * 100)}% + +
+
+
+ ${this._renderChanges(diff.changes)} +
+
+ `; + } + + _renderChanges(changes) { + return changes.map(change => ` +
+
+ ${change.type} + ${change.file} +
+
+
+
Previous
+
${this._escapeHtml(change.old_value)}
+
+
+
New
+
${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 = ` + + `; + } else { + controlsElement.innerHTML = ` + + `; + } + } + // 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