mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 13:11:08 -05:00
Add comment and reply functionality with preview and notification templates
This commit is contained in:
237
history_tracking/comparison.py
Normal file
237
history_tracking/comparison.py
Normal file
@@ -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}"
|
||||||
123
history_tracking/htmx_views.py
Normal file
123
history_tracking/htmx_views.py
Normal file
@@ -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')
|
||||||
|
})
|
||||||
@@ -5,6 +5,8 @@ from django.utils import timezone
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from collections import Counter
|
||||||
|
import json
|
||||||
from .models import VersionBranch, VersionTag, ChangeSet
|
from .models import VersionBranch, VersionTag, ChangeSet
|
||||||
|
|
||||||
UserModel = TypeVar('UserModel', bound=AbstractUser)
|
UserModel = TypeVar('UserModel', bound=AbstractUser)
|
||||||
@@ -73,6 +75,142 @@ class BranchManager:
|
|||||||
queryset = queryset.filter(is_active=True)
|
queryset = queryset.filter(is_active=True)
|
||||||
return list(queryset)
|
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:
|
class ChangeTracker:
|
||||||
"""Tracks and manages changes across the system"""
|
"""Tracks and manages changes across the system"""
|
||||||
|
|
||||||
@@ -114,6 +252,189 @@ class ChangeTracker:
|
|||||||
"""Get all changes in a branch ordered by creation time"""
|
"""Get all changes in a branch ordered by creation time"""
|
||||||
return list(ChangeSet.objects.filter(branch=branch).order_by('created_at'))
|
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('<!DOCTYPE') or value.startswith('<html'):
|
||||||
|
return 'html'
|
||||||
|
if value.startswith('// ') or value.startswith('function '):
|
||||||
|
return 'javascript'
|
||||||
|
|
||||||
|
syntax_map = {
|
||||||
|
'JSONField': 'json',
|
||||||
|
'FileField': 'path',
|
||||||
|
'FilePathField': 'path',
|
||||||
|
'URLField': 'url',
|
||||||
|
'EmailField': 'email',
|
||||||
|
'TextField': 'text',
|
||||||
|
'CharField': 'text'
|
||||||
|
}
|
||||||
|
return syntax_map.get(field_type, 'text')
|
||||||
|
|
||||||
|
def _compute_line_numbers(self, old_value: Any, new_value: Any) -> 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:
|
class MergeStrategy:
|
||||||
"""Handles merge operations and conflict resolution"""
|
"""Handles merge operations and conflict resolution"""
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# history_tracking/mixins.py
|
# history_tracking/mixins.py
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
|
|
||||||
class HistoricalChangeMixin(models.Model):
|
class HistoricalChangeMixin(models.Model):
|
||||||
"""Mixin for historical models to track changes"""
|
"""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)
|
id = models.BigIntegerField(db_index=True, auto_created=True, blank=True)
|
||||||
history_date = models.DateTimeField()
|
history_date = models.DateTimeField()
|
||||||
history_id = models.AutoField(primary_key=True)
|
history_id = models.AutoField(primary_key=True)
|
||||||
@@ -33,6 +35,7 @@ class HistoricalChangeMixin(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def diff_against_previous(self):
|
def diff_against_previous(self):
|
||||||
|
"""Get enhanced diff with syntax highlighting and metadata"""
|
||||||
prev_record = self.prev_record
|
prev_record = self.prev_record
|
||||||
if not prev_record:
|
if not prev_record:
|
||||||
return {}
|
return {}
|
||||||
@@ -54,11 +57,69 @@ class HistoricalChangeMixin(models.Model):
|
|||||||
old_value = getattr(prev_record, field)
|
old_value = getattr(prev_record, field)
|
||||||
new_value = getattr(self, field)
|
new_value = getattr(self, field)
|
||||||
if old_value != new_value:
|
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:
|
except AttributeError:
|
||||||
continue
|
continue
|
||||||
return changes
|
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
|
@property
|
||||||
def history_user_display(self):
|
def history_user_display(self):
|
||||||
"""Get a display name for the history user"""
|
"""Get a display name for the history user"""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib.contenttypes.models import ContentType
|
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 django.contrib.auth import get_user_model
|
||||||
from simple_history.models import HistoricalRecords
|
from simple_history.models import HistoricalRecords
|
||||||
from .mixins import HistoricalChangeMixin
|
from .mixins import HistoricalChangeMixin
|
||||||
@@ -60,6 +60,14 @@ class VersionBranch(models.Model):
|
|||||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
is_active = models.BooleanField(default=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:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
@@ -91,6 +99,10 @@ class VersionTag(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
comparison_metadata = models.JSONField(
|
||||||
|
default=dict,
|
||||||
|
help_text="Stores diff statistics and comparison results"
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
@@ -104,6 +116,74 @@ class VersionTag(models.Model):
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.name} ({self.branch.name})"
|
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):
|
class ChangeSet(models.Model):
|
||||||
"""Groups related changes together for atomic version control operations"""
|
"""Groups related changes together for atomic version control operations"""
|
||||||
branch = models.ForeignKey(VersionBranch, on_delete=models.CASCADE, related_name='changesets')
|
branch = models.ForeignKey(VersionBranch, on_delete=models.CASCADE, related_name='changesets')
|
||||||
@@ -115,12 +195,41 @@ class ChangeSet(models.Model):
|
|||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=[
|
choices=[
|
||||||
('pending', 'Pending'),
|
('draft', 'Draft'),
|
||||||
|
('pending_approval', 'Pending Approval'),
|
||||||
|
('approved', 'Approved'),
|
||||||
|
('rejected', 'Rejected'),
|
||||||
('applied', 'Applied'),
|
('applied', 'Applied'),
|
||||||
('failed', 'Failed'),
|
('failed', 'Failed'),
|
||||||
('reverted', 'Reverted')
|
('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
|
# Instead of directly relating to HistoricalRecord, use GenericForeignKey
|
||||||
|
|||||||
229
history_tracking/notifications.py
Normal file
229
history_tracking/notifications.py
Normal file
@@ -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}"
|
||||||
194
history_tracking/state_machine.py
Normal file
194
history_tracking/state_machine.py
Normal file
@@ -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)
|
||||||
174
history_tracking/templates/history_tracking/approval_status.html
Normal file
174
history_tracking/templates/history_tracking/approval_status.html
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="approval-status">
|
||||||
|
<div class="status-header">
|
||||||
|
<h2>Approval Status</h2>
|
||||||
|
<div class="changeset-info">
|
||||||
|
<span class="changeset-id">Changeset #{{ changeset.pk }}</span>
|
||||||
|
<span class="status-badge {{ changeset.status }}">{{ changeset.status|title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if changeset.description %}
|
||||||
|
<div class="changeset-description">
|
||||||
|
{{ changeset.description }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_stage %}
|
||||||
|
<div class="current-stage">
|
||||||
|
<h3>Current Stage: {{ current_stage.name }}</h3>
|
||||||
|
|
||||||
|
<div class="required-roles">
|
||||||
|
<h4>Required Approvers:</h4>
|
||||||
|
<ul class="role-list">
|
||||||
|
{% for role in current_stage.required_roles %}
|
||||||
|
<li class="role-item">{{ role }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="approvers">
|
||||||
|
<h4>Current Approvers:</h4>
|
||||||
|
{% if current_stage.approvers %}
|
||||||
|
<ul class="approvers-list">
|
||||||
|
{% for approver in current_stage.approvers %}
|
||||||
|
<li class="approver-item">
|
||||||
|
<div class="approver-info">
|
||||||
|
<span class="approver-name">{{ approver.user }}</span>
|
||||||
|
<span class="approval-date">{{ approver.timestamp|date:"Y-m-d H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
{% if approver.comment %}
|
||||||
|
<div class="approval-comment">
|
||||||
|
{{ approver.comment }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="no-approvers">No approvals yet</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_approve %}
|
||||||
|
<div class="approval-actions">
|
||||||
|
<form method="post" action="{% url 'history_tracking:approve_changes' changeset.pk %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="comment">Comment (optional):</label>
|
||||||
|
<textarea name="comment" id="comment" rows="3" class="form-control"></textarea>
|
||||||
|
</div>
|
||||||
|
{% if current_stage.id %}
|
||||||
|
<input type="hidden" name="stage_id" value="{{ current_stage.id }}">
|
||||||
|
{% endif %}
|
||||||
|
<div class="approval-buttons">
|
||||||
|
<button type="submit" name="decision" value="approve" class="btn btn-success">
|
||||||
|
Approve Changes
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="decision" value="reject" class="btn btn-danger">
|
||||||
|
Reject Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="approval-history">
|
||||||
|
<h3>Approval History</h3>
|
||||||
|
{% if changeset.approval_history %}
|
||||||
|
<div class="history-timeline">
|
||||||
|
{% for entry in changeset.approval_history %}
|
||||||
|
<div class="history-entry">
|
||||||
|
<div class="entry-header">
|
||||||
|
<span class="entry-stage">{{ entry.stage_name }}</span>
|
||||||
|
<span class="entry-date">{{ entry.timestamp|date:"Y-m-d H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-content">
|
||||||
|
<span class="entry-user">{{ entry.user }}</span>
|
||||||
|
<span class="entry-action">{{ entry.action|title }}</span>
|
||||||
|
{% if entry.comment %}
|
||||||
|
<div class="entry-comment">
|
||||||
|
{{ entry.comment }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="no-history">No approval history yet</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if pending_approvers %}
|
||||||
|
<div class="pending-approvers">
|
||||||
|
<h3>Waiting for Approval From:</h3>
|
||||||
|
<ul class="pending-list">
|
||||||
|
{% for role in pending_approvers %}
|
||||||
|
<li class="pending-role">{{ role }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="messages">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.pending_approval { background-color: #fef3c7; color: #92400e; }
|
||||||
|
.status-badge.approved { background-color: #dcfce7; color: #166534; }
|
||||||
|
.status-badge.rejected { background-color: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
|
.history-timeline {
|
||||||
|
border-left: 2px solid #e5e7eb;
|
||||||
|
margin-left: 1rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-entry::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -1.5rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-actions {
|
||||||
|
margin-top: 2rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% if notifications %}
|
||||||
|
<div class="approval-notifications" id="approval-notifications">
|
||||||
|
{% for notification in notifications %}
|
||||||
|
<div class="notification-item {% if notification.is_new %}new{% endif %}"
|
||||||
|
{% if notification.is_new %}
|
||||||
|
hx-swap-oob="afterbegin:.approval-notifications"
|
||||||
|
{% endif %}>
|
||||||
|
<div class="notification-header">
|
||||||
|
<span class="notification-type">{{ notification.type }}</span>
|
||||||
|
<span class="notification-time">{{ notification.timestamp|timesince }} ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="notification-content">
|
||||||
|
{% if notification.type == 'approval' %}
|
||||||
|
<span class="approver">{{ notification.user }}</span>
|
||||||
|
{{ notification.action }} the changes
|
||||||
|
{% if notification.stage %}
|
||||||
|
in stage <strong>{{ notification.stage }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
{% elif notification.type == 'comment' %}
|
||||||
|
<span class="commenter">{{ notification.user }}</span>
|
||||||
|
commented on the changes
|
||||||
|
{% elif notification.type == 'stage_change' %}
|
||||||
|
Moved to stage <strong>{{ notification.stage }}</strong>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if notification.comment %}
|
||||||
|
<div class="notification-comment">
|
||||||
|
"{{ notification.comment }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if notification.actions %}
|
||||||
|
<div class="notification-actions">
|
||||||
|
{% for action in notification.actions %}
|
||||||
|
<button class="btn btn-sm btn-link"
|
||||||
|
hx-post="{{ action.url }}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#approval-status-container">
|
||||||
|
{{ action.label }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="no-notifications" id="approval-notifications">
|
||||||
|
No recent notifications
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
{% if current_stage %}
|
||||||
|
<div id="approval-status-container">
|
||||||
|
<div class="current-stage-info">
|
||||||
|
<h3>Current Stage: {{ current_stage.name }}</h3>
|
||||||
|
|
||||||
|
<div class="required-roles">
|
||||||
|
<h4>Required Approvers:</h4>
|
||||||
|
<ul class="role-list">
|
||||||
|
{% for role in current_stage.required_roles %}
|
||||||
|
<li class="role-item {% if role in pending_approvers %}pending{% endif %}">
|
||||||
|
{{ role }}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="approvers">
|
||||||
|
<h4>Current Approvers:</h4>
|
||||||
|
{% if current_stage.approvers %}
|
||||||
|
<ul class="approvers-list">
|
||||||
|
{% for approver in current_stage.approvers %}
|
||||||
|
<li class="approver-item">
|
||||||
|
<div class="approver-info">
|
||||||
|
<span class="approver-name">{{ approver.user }}</span>
|
||||||
|
<span class="approval-date">{{ approver.timestamp|date:"Y-m-d H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
{% if approver.comment %}
|
||||||
|
<div class="approval-comment">
|
||||||
|
{{ approver.comment }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="no-approvers">No approvals yet</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if can_approve %}
|
||||||
|
<div class="approval-actions">
|
||||||
|
<form hx-post="{% url 'history_tracking:approve_changes' changeset.pk %}"
|
||||||
|
hx-trigger="submit"
|
||||||
|
hx-target="#approval-status-container"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="comment">Comment (optional):</label>
|
||||||
|
<textarea name="comment"
|
||||||
|
id="comment"
|
||||||
|
rows="3"
|
||||||
|
class="form-control"
|
||||||
|
hx-post="{% url 'history_tracking:preview_comment' %}"
|
||||||
|
hx-trigger="keyup changed delay:500ms"
|
||||||
|
hx-target="#comment-preview"></textarea>
|
||||||
|
<div id="comment-preview" class="comment-preview"></div>
|
||||||
|
</div>
|
||||||
|
{% if current_stage.id %}
|
||||||
|
<input type="hidden" name="stage_id" value="{{ current_stage.id }}">
|
||||||
|
{% endif %}
|
||||||
|
<div class="approval-buttons">
|
||||||
|
<button type="submit"
|
||||||
|
name="decision"
|
||||||
|
value="approve"
|
||||||
|
class="btn btn-success"
|
||||||
|
{% if not can_approve %}disabled{% endif %}
|
||||||
|
hx-indicator="#approve-indicator">
|
||||||
|
Approve Changes
|
||||||
|
<span class="htmx-indicator" id="approve-indicator">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
name="decision"
|
||||||
|
value="reject"
|
||||||
|
class="btn btn-danger"
|
||||||
|
{% if not can_approve %}disabled{% endif %}
|
||||||
|
hx-indicator="#reject-indicator">
|
||||||
|
Reject Changes
|
||||||
|
<span class="htmx-indicator" id="reject-indicator">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
<div class="approval-messages"
|
||||||
|
hx-swap-oob="true">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
|
||||||
|
role="alert">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{% if content %}
|
||||||
|
<div class="comment-preview-content">
|
||||||
|
<h6>Preview:</h6>
|
||||||
|
<div class="preview-text">{{ content }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% if replies %}
|
||||||
|
<div class="replies-list">
|
||||||
|
{% for reply in replies %}
|
||||||
|
<div class="reply" id="comment-{{ reply.id }}">
|
||||||
|
<div class="reply-header">
|
||||||
|
<span class="reply-author">{{ reply.author }}</span>
|
||||||
|
<span class="reply-date">{{ reply.created_at|date:"Y-m-d H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="reply-content">
|
||||||
|
{{ reply.content }}
|
||||||
|
</div>
|
||||||
|
{% if user.has_perm 'history_tracking.add_comment' %}
|
||||||
|
<div class="reply-actions">
|
||||||
|
<button class="btn btn-sm btn-link"
|
||||||
|
hx-get="{% url 'history_tracking:reply_form' %}?parent_id={{ reply.id }}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#reply-form-{{ reply.id }}"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="reply-form-{{ reply.id }}"></div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% if comments %}
|
||||||
|
<div class="comments-list">
|
||||||
|
{% for comment in comments %}
|
||||||
|
<div class="comment" id="comment-{{ comment.id }}"
|
||||||
|
{% if forloop.first %}hx-swap-oob="true"{% endif %}>
|
||||||
|
<div class="comment-header">
|
||||||
|
<span class="comment-author">{{ comment.author }}</span>
|
||||||
|
<span class="comment-date">{{ comment.created_at|date:"Y-m-d H:i" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">{{ comment.content }}</div>
|
||||||
|
{% if user.has_perm 'history_tracking.add_comment' %}
|
||||||
|
<div class="comment-actions">
|
||||||
|
<button class="btn btn-sm btn-link"
|
||||||
|
hx-get="{% url 'history_tracking:reply_form' %}"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-target="#reply-form-{{ comment.id }}"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="reply-form-{{ comment.id }}"></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if comment.replies.exists %}
|
||||||
|
<div class="comment-replies"
|
||||||
|
hx-get="{% url 'history_tracking:get_replies' comment.id %}"
|
||||||
|
hx-trigger="load">
|
||||||
|
<div class="htmx-indicator">Loading replies...</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="no-comments">No comments yet</p>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<div class="reply-form-container">
|
||||||
|
<form hx-post="{% url 'history_tracking:add_comment' %}"
|
||||||
|
hx-trigger="submit"
|
||||||
|
hx-target="closest .comments-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="reply-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="parent_id" value="{{ parent_id }}">
|
||||||
|
<input type="hidden" name="anchor" value="{{ anchor }}">
|
||||||
|
<div class="form-group">
|
||||||
|
<textarea name="content"
|
||||||
|
class="form-control reply-input"
|
||||||
|
placeholder="Write a reply..."
|
||||||
|
rows="2"
|
||||||
|
hx-post="{% url 'history_tracking:preview_comment' %}"
|
||||||
|
hx-trigger="keyup changed delay:500ms"
|
||||||
|
hx-target="#reply-preview-{{ parent_id }}"></textarea>
|
||||||
|
<div id="reply-preview-{{ parent_id }}" class="reply-preview"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button"
|
||||||
|
class="btn btn-sm btn-light"
|
||||||
|
onclick="this.closest('.reply-form-container').remove()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
hx-disable-if="querySelector('.reply-input').value === ''">
|
||||||
|
Submit Reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="version-comparison">
|
||||||
|
<div class="comparison-header">
|
||||||
|
<h2>Version Comparison</h2>
|
||||||
|
|
||||||
|
{# Version Selection Form with HTMX #}
|
||||||
|
<div class="version-select-form">
|
||||||
|
<div class="version-selectors">
|
||||||
|
<div class="version-select">
|
||||||
|
<label for="version1">First Version</label>
|
||||||
|
<select name="version1" id="version1"
|
||||||
|
hx-get="{% url 'history_tracking:version_comparison' %}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#comparison-results"
|
||||||
|
hx-indicator=".loading-indicator">
|
||||||
|
{% for version in versions %}
|
||||||
|
<option value="{{ version.id }}" {% if version.id == selected_version1 %}selected{% endif %}>
|
||||||
|
{{ version.name }} ({{ version.created_at|date:"Y-m-d H:i" }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="version-select">
|
||||||
|
<label for="version2">Second Version</label>
|
||||||
|
<select name="version2" id="version2"
|
||||||
|
hx-get="{% url 'history_tracking:version_comparison' %}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target="#comparison-results"
|
||||||
|
hx-indicator=".loading-indicator">
|
||||||
|
{% for version in versions %}
|
||||||
|
<option value="{{ version.id }}" {% if version.id == selected_version2 %}selected{% endif %}>
|
||||||
|
{{ version.name }} ({{ version.created_at|date:"Y-m-d H:i" }})
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="loading-indicator htmx-indicator">Loading comparison...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if diff_result %}
|
||||||
|
<div class="comparison-results">
|
||||||
|
<div class="diff-summary">
|
||||||
|
<h3>Changes Summary</h3>
|
||||||
|
<div class="diff-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Files Changed:</span>
|
||||||
|
<span class="stat-value">{{ diff_result.stats.total_files }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Lines Changed:</span>
|
||||||
|
<span class="stat-value">{{ diff_result.stats.total_lines }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Impact Score:</span>
|
||||||
|
<span class="stat-value">{{ diff_result.impact_score|floatformat:2 }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="changes-list">
|
||||||
|
{% for change in diff_result.changes %}
|
||||||
|
<div class="change-item">
|
||||||
|
<div class="change-header">
|
||||||
|
<h4>{{ change.field }}</h4>
|
||||||
|
<span class="change-type">{{ change.type }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="diff-view {% if change.syntax_type %}language-{{ change.syntax_type }}{% endif %}">
|
||||||
|
<div class="old-version">
|
||||||
|
<div class="version-header">Previous Version</div>
|
||||||
|
<pre><code>{{ change.old }}</code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="new-version">
|
||||||
|
<div class="version-header">New Version</div>
|
||||||
|
<pre><code>{{ change.new }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if user.has_perm 'history_tracking.add_comment' %}
|
||||||
|
<div class="comment-section"
|
||||||
|
id="comments-{{ change.metadata.comment_anchor_id }}"
|
||||||
|
hx-get="{% url 'history_tracking:get_comments' %}"
|
||||||
|
hx-trigger="load, commentAdded from:body"
|
||||||
|
hx-vals='{"anchor": "{{ change.metadata.comment_anchor_id }}"}'>
|
||||||
|
<div class="htmx-indicator">Loading comments...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comment-form-container">
|
||||||
|
<form hx-post="{% url 'history_tracking:add_comment' %}"
|
||||||
|
hx-trigger="submit"
|
||||||
|
hx-target="#comments-{{ change.metadata.comment_anchor_id }}"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="comment-form">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="anchor" value="{{ change.metadata.comment_anchor_id }}">
|
||||||
|
<textarea name="content"
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
hx-post="{% url 'history_tracking:preview_comment' %}"
|
||||||
|
hx-trigger="keyup changed delay:500ms"
|
||||||
|
hx-target="#comment-preview-{{ change.metadata.comment_anchor_id }}"
|
||||||
|
class="comment-input"></textarea>
|
||||||
|
<div id="comment-preview-{{ change.metadata.comment_anchor_id }}"
|
||||||
|
class="comment-preview"></div>
|
||||||
|
<button type="submit"
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
hx-disable-if="querySelector('.comment-input').value === ''">
|
||||||
|
Comment
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if diff_result.changes.has_other_pages %}
|
||||||
|
<div class="pagination">
|
||||||
|
<span class="step-links">
|
||||||
|
{% if diff_result.changes.has_previous %}
|
||||||
|
<a href="?page=1">« first</a>
|
||||||
|
<a href="?page={{ diff_result.changes.previous_page_number }}">previous</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<span class="current">
|
||||||
|
Page {{ diff_result.changes.number }} of {{ diff_result.changes.paginator.num_pages }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if diff_result.changes.has_next %}
|
||||||
|
<a href="?page={{ diff_result.changes.next_page_number }}">next</a>
|
||||||
|
<a href="?page={{ diff_result.changes.paginator.num_pages }}">last »</a>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
Only include minimal JavaScript for necessary interactivity
|
||||||
|
{% endcomment %}
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Handle syntax highlighting
|
||||||
|
document.querySelectorAll('pre code').forEach((block) => {
|
||||||
|
const language = block.parentElement.parentElement.dataset.language;
|
||||||
|
if (language) {
|
||||||
|
block.classList.add(`language-${language}`);
|
||||||
|
hljs.highlightElement(block);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simple form validation
|
||||||
|
document.querySelector('.version-select-form').addEventListener('submit', function(e) {
|
||||||
|
const v1 = document.getElementById('version1').value;
|
||||||
|
const v2 = document.getElementById('version2').value;
|
||||||
|
if (v1 === v2) {
|
||||||
|
e.preventDefault();
|
||||||
|
alert('Please select different versions to compare');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,22 +1,50 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views
|
from . import views, htmx_views
|
||||||
|
|
||||||
app_name = 'history'
|
app_name = 'history_tracking'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Main VCS interface
|
# Main page views
|
||||||
path('vcs/', views.VersionControlPanel.as_view(), name='vcs-panel'),
|
path('compare/',
|
||||||
|
views.version_comparison,
|
||||||
|
name='version_comparison'
|
||||||
|
),
|
||||||
|
path('approval-status/<int:changeset_id>/',
|
||||||
|
views.approval_status,
|
||||||
|
name='approval_status'
|
||||||
|
),
|
||||||
|
path('submit-approval/<int:changeset_id>/',
|
||||||
|
views.submit_for_approval,
|
||||||
|
name='submit_for_approval'
|
||||||
|
),
|
||||||
|
|
||||||
# Branch operations
|
# HTMX endpoints
|
||||||
path('vcs/branches/', views.BranchListView.as_view(), name='branch-list'),
|
path('htmx/comments/get/',
|
||||||
path('vcs/branches/create/', views.BranchCreateView.as_view(), name='branch-create'),
|
htmx_views.get_comments,
|
||||||
|
name='get_comments'
|
||||||
# History views
|
),
|
||||||
path('vcs/history/', views.HistoryView.as_view(), name='history-view'),
|
path('htmx/comments/preview/',
|
||||||
|
htmx_views.preview_comment,
|
||||||
# Merge operations
|
name='preview_comment'
|
||||||
path('vcs/merge/', views.MergeView.as_view(), name='merge-view'),
|
),
|
||||||
|
path('htmx/comments/add/',
|
||||||
# Tag operations
|
htmx_views.add_comment,
|
||||||
path('vcs/tags/create/', views.TagCreateView.as_view(), name='tag-create'),
|
name='add_comment'
|
||||||
|
),
|
||||||
|
path('htmx/approve/<int:changeset_id>/',
|
||||||
|
htmx_views.approve_changes,
|
||||||
|
name='approve_changes'
|
||||||
|
),
|
||||||
|
path('htmx/notifications/<int:changeset_id>/',
|
||||||
|
htmx_views.approval_notifications,
|
||||||
|
name='approval_notifications'
|
||||||
|
),
|
||||||
|
path('htmx/comments/replies/<int:comment_id>/',
|
||||||
|
htmx_views.get_replies,
|
||||||
|
name='get_replies'
|
||||||
|
),
|
||||||
|
path('htmx/comments/reply-form/',
|
||||||
|
htmx_views.reply_form,
|
||||||
|
name='reply_form'
|
||||||
|
),
|
||||||
]
|
]
|
||||||
@@ -1,238 +1,144 @@
|
|||||||
from django.views.generic import TemplateView, View
|
from django.shortcuts import render, get_object_or_404
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.shortcuts import get_object_or_404
|
from django.core.paginator import Paginator
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.utils import timezone
|
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 .models import VersionBranch, ChangeSet, VersionTag, CommentThread
|
||||||
from .managers import BranchManager, ChangeTracker, MergeStrategy
|
from .managers import ChangeTracker
|
||||||
|
from .comparison import ComparisonEngine
|
||||||
|
from .state_machine import ApprovalStateMachine
|
||||||
|
|
||||||
class VersionControlPanel(LoginRequiredMixin, TemplateView):
|
ITEMS_PER_PAGE = 20
|
||||||
"""Main version control interface"""
|
|
||||||
template_name = 'history_tracking/version_control_panel.html'
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
@login_required
|
||||||
context = super().get_context_data(**kwargs)
|
def version_comparison(request: HttpRequest) -> HttpResponse:
|
||||||
branch_manager = BranchManager()
|
"""View for comparing different versions"""
|
||||||
|
versions = VersionTag.objects.all().order_by('-created_at')
|
||||||
|
|
||||||
context.update({
|
version1_id = request.GET.get('version1')
|
||||||
'branches': branch_manager.list_branches(),
|
version2_id = request.GET.get('version2')
|
||||||
'current_branch': self.request.GET.get('branch'),
|
page_number = request.GET.get('page', 1)
|
||||||
})
|
|
||||||
return context
|
|
||||||
|
|
||||||
class BranchListView(LoginRequiredMixin, View):
|
diff_result = None
|
||||||
"""HTMX view for branch list"""
|
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)
|
||||||
|
|
||||||
def get(self, request):
|
# Get comparison results
|
||||||
branch_manager = BranchManager()
|
engine = ComparisonEngine()
|
||||||
branches = branch_manager.list_branches()
|
diff_result = engine.compute_enhanced_diff(version1, version2)
|
||||||
|
|
||||||
content = render_to_string(
|
# Paginate changes
|
||||||
'history_tracking/components/branch_list.html',
|
paginator = Paginator(diff_result['changes'], ITEMS_PER_PAGE)
|
||||||
{'branches': branches},
|
diff_result['changes'] = paginator.get_page(page_number)
|
||||||
request=request
|
|
||||||
)
|
|
||||||
return HttpResponse(content)
|
|
||||||
|
|
||||||
class HistoryView(LoginRequiredMixin, View):
|
# Add comments to changes
|
||||||
"""HTMX view for change history"""
|
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')
|
||||||
|
|
||||||
def get(self, request):
|
except Exception as e:
|
||||||
branch_name = request.GET.get('branch')
|
messages.error(request, f"Error comparing versions: {str(e)}")
|
||||||
if not branch_name:
|
|
||||||
return HttpResponse("No branch selected")
|
|
||||||
|
|
||||||
branch = get_object_or_404(VersionBranch, name=branch_name)
|
context = {
|
||||||
tracker = ChangeTracker()
|
'versions': versions,
|
||||||
changes = tracker.get_changes(branch)
|
'selected_version1': version1_id,
|
||||||
|
'selected_version2': version2_id,
|
||||||
|
'diff_result': diff_result
|
||||||
|
}
|
||||||
|
|
||||||
content = render_to_string(
|
return render(request, 'history_tracking/version_comparison.html', context)
|
||||||
'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")
|
|
||||||
|
|
||||||
content = render_to_string(
|
|
||||||
'history_tracking/components/merge_panel.html',
|
|
||||||
{
|
|
||||||
'source': source,
|
|
||||||
'target': target
|
|
||||||
},
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
return HttpResponse(content)
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_http_methods(["POST"])
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def post(self, request):
|
def submit_for_approval(request: HttpRequest, changeset_id: int) -> HttpResponse:
|
||||||
source_name = request.POST.get('source')
|
"""Submit a changeset for approval"""
|
||||||
target_name = request.POST.get('target')
|
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
|
||||||
|
|
||||||
if not (source_name and target_name):
|
if not request.user.has_perm('history_tracking.submit_for_approval'):
|
||||||
return JsonResponse({
|
raise PermissionDenied("You don't have permission to submit changes for approval")
|
||||||
'error': 'Source and target branches required'
|
|
||||||
}, status=400)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
source = get_object_or_404(VersionBranch, name=source_name)
|
# Initialize approval workflow
|
||||||
target = get_object_or_404(VersionBranch, name=target_name)
|
state_machine = ApprovalStateMachine(changeset)
|
||||||
|
stages_config = [
|
||||||
|
{
|
||||||
|
'name': 'Technical Review',
|
||||||
|
'required_roles': ['tech_reviewer']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Final Approval',
|
||||||
|
'required_roles': ['approver']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
branch_manager = BranchManager()
|
state_machine.initialize_workflow(stages_config)
|
||||||
success, conflicts = branch_manager.merge_branches(
|
changeset.status = 'pending_approval'
|
||||||
source=source,
|
changeset.save()
|
||||||
target=target,
|
|
||||||
user=request.user
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'changeset': changeset,
|
||||||
|
'current_stage': current_stage,
|
||||||
|
'can_approve': state_machine.can_user_approve(request.user),
|
||||||
|
'pending_approvers': state_machine.get_pending_approvers()
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, 'history_tracking/approval_status.html', context)
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
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 success:
|
if success:
|
||||||
content = render_to_string(
|
messages.success(request, f"Successfully {decision}d changes")
|
||||||
'history_tracking/components/merge_success.html',
|
|
||||||
{'source': source, 'target': target},
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
return HttpResponse(content)
|
|
||||||
else:
|
else:
|
||||||
content = render_to_string(
|
messages.error(request, "Failed to submit approval")
|
||||||
'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:
|
except Exception as e:
|
||||||
return JsonResponse(
|
messages.error(request, f"Error processing approval: {str(e)}")
|
||||||
{'error': 'Merge failed. Please try again.'},
|
|
||||||
status=500
|
|
||||||
)
|
|
||||||
|
|
||||||
class BranchCreateView(LoginRequiredMixin, View):
|
return render(request, 'history_tracking/approval_status.html', {
|
||||||
"""HTMX view for branch creation"""
|
'changeset': changeset
|
||||||
|
})
|
||||||
def get(self, request):
|
|
||||||
content = render_to_string(
|
|
||||||
'history_tracking/components/branch_create.html',
|
|
||||||
request=request
|
|
||||||
)
|
|
||||||
return HttpResponse(content)
|
|
||||||
|
|
||||||
@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
|
|
||||||
)
|
|
||||||
|
|
||||||
class TagCreateView(LoginRequiredMixin, View):
|
|
||||||
"""HTMX view for version tagging"""
|
|
||||||
|
|
||||||
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')
|
|
||||||
|
|
||||||
if not (name and branch_name):
|
|
||||||
return JsonResponse(
|
|
||||||
{'error': 'Tag name and branch required'},
|
|
||||||
status=400
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -19,73 +19,14 @@
|
|||||||
- [x] Merge Panel
|
- [x] Merge Panel
|
||||||
- [x] Branch Creation Form
|
- [x] Branch Creation Form
|
||||||
|
|
||||||
## Asset Integration ✓
|
## Future Enhancements
|
||||||
- [x] JavaScript
|
- [ ] Add visual diff viewer - [See Visual Diff Viewer Plan](visual-diff-viewer.md)
|
||||||
- [x] Version Control core functionality
|
- [ ] Implement branch locking - [See Branch Locking System](branch-locking.md)
|
||||||
- [x] HTMX integration
|
- [ ] Add commenting on changes - [See Change Comments Framework](change-comments.md)
|
||||||
- [x] Event handling
|
- [ ] Create change approval workflow - [See Approval Workflow Docs](approval-workflow.md)
|
||||||
|
- [ ] Add version comparison tool - [See Comparison Tool Spec](version-comparison.md)
|
||||||
|
|
||||||
- [x] CSS
|
## Documentation Updates ✓
|
||||||
- [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 ✓
|
|
||||||
- [x] README creation
|
- [x] README creation
|
||||||
- [x] Implementation guide
|
- [x] Implementation guide
|
||||||
- [x] Template integration guide
|
- [x] Template integration guide
|
||||||
@@ -94,85 +35,9 @@
|
|||||||
|
|
||||||
## Testing Requirements ✓
|
## Testing Requirements ✓
|
||||||
- [x] Unit Tests
|
- [x] Unit Tests
|
||||||
- [x] Model tests
|
|
||||||
- [x] Manager tests
|
|
||||||
- [x] View tests
|
|
||||||
- [x] Form tests
|
|
||||||
|
|
||||||
- [x] Integration Tests
|
- [x] Integration Tests
|
||||||
- [x] Branch operations
|
|
||||||
- [x] Merge operations
|
|
||||||
- [x] Change tracking
|
|
||||||
- [x] UI interactions
|
|
||||||
|
|
||||||
- [x] UI Tests
|
- [x] UI Tests
|
||||||
- [x] Component rendering
|
|
||||||
- [x] User interactions
|
|
||||||
- [x] Responsive design
|
|
||||||
- [x] Browser compatibility
|
|
||||||
|
|
||||||
## Monitoring Setup ✓
|
## Monitoring Setup ✓
|
||||||
- [x] Performance Metrics
|
- [x] Performance Metrics
|
||||||
- [x] Branch operation timing
|
|
||||||
- [x] Merge success rates
|
|
||||||
- [x] Change tracking overhead
|
|
||||||
- [x] UI responsiveness
|
|
||||||
|
|
||||||
- [x] Error Tracking
|
- [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
|
|
||||||
332
static/css/approval-panel.css
Normal file
332
static/css/approval-panel.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
195
static/css/diff-viewer.css
Normal file
195
static/css/diff-viewer.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
229
static/css/inline-comment-panel.css
Normal file
229
static/css/inline-comment-panel.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
353
static/css/version-comparison.css
Normal file
353
static/css/version-comparison.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,6 +142,115 @@
|
|||||||
margin: 0.25rem;
|
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 */
|
/* Loading States */
|
||||||
.vcs-loading {
|
.vcs-loading {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
203
static/js/collaboration-system.js
Normal file
203
static/js/collaboration-system.js
Normal file
@@ -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;
|
||||||
234
static/js/components/approval-panel.js
Normal file
234
static/js/components/approval-panel.js
Normal file
@@ -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 = `
|
||||||
|
<div class="approval-panel">
|
||||||
|
<div class="approval-header">
|
||||||
|
<h3 class="approval-title">Change Approval</h3>
|
||||||
|
${this._renderStatus()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="approval-stages">
|
||||||
|
${this._renderStages(approvalState)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${currentStage && this.changeset.status === 'pending_approval' ?
|
||||||
|
this._renderApprovalActions(currentStage) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="approval-history">
|
||||||
|
${this._renderHistory()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="approval-status ${status.class}">
|
||||||
|
${status.text}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderStages(stages) {
|
||||||
|
return stages.map((stage, index) => `
|
||||||
|
<div class="approval-stage ${stage.status}-stage">
|
||||||
|
<div class="stage-header">
|
||||||
|
<span class="stage-name">${stage.name}</span>
|
||||||
|
<span class="stage-status ${stage.status}">${
|
||||||
|
this._formatStageStatus(stage.status)
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
<div class="stage-details">
|
||||||
|
<div class="required-roles">
|
||||||
|
${stage.required_roles.map(role => `
|
||||||
|
<span class="role-badge">${role}</span>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
${stage.approvers.length > 0 ? `
|
||||||
|
<div class="approvers-list">
|
||||||
|
${stage.approvers.map(approver => this._renderApprover(approver)).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderApprover(approver) {
|
||||||
|
const decisionClass = approver.decision === 'approve' ? 'approved' : 'rejected';
|
||||||
|
return `
|
||||||
|
<div class="approver">
|
||||||
|
<div class="approver-info">
|
||||||
|
<span class="approver-name">${approver.username}</span>
|
||||||
|
<span class="approval-date">
|
||||||
|
${new Date(approver.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="approver-decision ${decisionClass}">
|
||||||
|
${approver.decision === 'approve' ? 'Approved' : 'Rejected'}
|
||||||
|
</div>
|
||||||
|
${approver.comment ? `
|
||||||
|
<div class="approver-comment">${approver.comment}</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderApprovalActions(currentStage) {
|
||||||
|
if (!this.canUserApprove(currentStage)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="approval-actions">
|
||||||
|
<textarea
|
||||||
|
class="approval-comment"
|
||||||
|
placeholder="Add your comments (optional)"
|
||||||
|
></textarea>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button class="reject-button" onclick="this.handleReject()">
|
||||||
|
Reject Changes
|
||||||
|
</button>
|
||||||
|
<button class="approve-button" onclick="this.handleApprove()">
|
||||||
|
Approve Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderHistory() {
|
||||||
|
if (!this.changeset.approval_history?.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="approval-history-list">
|
||||||
|
${this.changeset.approval_history.map(entry => `
|
||||||
|
<div class="history-entry">
|
||||||
|
<div class="entry-header">
|
||||||
|
<span class="entry-user">${entry.username}</span>
|
||||||
|
<span class="entry-date">
|
||||||
|
${new Date(entry.timestamp).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-action ${entry.action}">
|
||||||
|
${this._formatHistoryAction(entry.action)}
|
||||||
|
</div>
|
||||||
|
${entry.comment ? `
|
||||||
|
<div class="entry-comment">${entry.comment}</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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;
|
||||||
274
static/js/components/diff-viewer.js
Normal file
274
static/js/components/diff-viewer.js
Normal file
@@ -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 = `
|
||||||
|
<div class="diff-viewer ${this.renderStrategy}">
|
||||||
|
<div class="diff-header">
|
||||||
|
${this.renderMetadata(metadata)}
|
||||||
|
${this.renderControls()}
|
||||||
|
</div>
|
||||||
|
<div class="diff-content">
|
||||||
|
${content}
|
||||||
|
</div>
|
||||||
|
${this.renderNavigation(navigation)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.attachEventListeners();
|
||||||
|
await this.highlightSyntax();
|
||||||
|
|
||||||
|
this.performance.endTime = performance.now();
|
||||||
|
this.updatePerformanceMetrics();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSideBySide(changes) {
|
||||||
|
return Object.entries(changes).map(([field, change]) => `
|
||||||
|
<div class="diff-section" data-field="${field}">
|
||||||
|
<div class="diff-field-header">
|
||||||
|
<span class="field-name">${field}</span>
|
||||||
|
<span class="syntax-type">${change.syntax_type}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diff-blocks">
|
||||||
|
<div class="diff-block old" data-anchor="${change.metadata.comment_anchor_id}-old">
|
||||||
|
<div class="line-numbers">
|
||||||
|
${this.renderLineNumbers(change.metadata.line_numbers.old)}
|
||||||
|
</div>
|
||||||
|
<pre><code class="language-${change.syntax_type}">${this.escapeHtml(change.old)}</code></pre>
|
||||||
|
</div>
|
||||||
|
<div class="diff-block new" data-anchor="${change.metadata.comment_anchor_id}-new">
|
||||||
|
<div class="line-numbers">
|
||||||
|
${this.renderLineNumbers(change.metadata.line_numbers.new)}
|
||||||
|
</div>
|
||||||
|
<pre><code class="language-${change.syntax_type}">${this.escapeHtml(change.new)}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInline(changes) {
|
||||||
|
return Object.entries(changes).map(([field, change]) => `
|
||||||
|
<div class="diff-section" data-field="${field}">
|
||||||
|
<div class="diff-field-header">
|
||||||
|
<span class="field-name">${field}</span>
|
||||||
|
<span class="syntax-type">${change.syntax_type}</span>
|
||||||
|
</div>
|
||||||
|
<div class="diff-block inline" data-anchor="${change.metadata.comment_anchor_id}">
|
||||||
|
<div class="line-numbers">
|
||||||
|
${this.renderLineNumbers(change.metadata.line_numbers.new)}
|
||||||
|
</div>
|
||||||
|
<pre><code class="language-${change.syntax_type}">
|
||||||
|
${this.renderInlineDiff(change.old, change.new)}
|
||||||
|
</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMetadata(metadata) {
|
||||||
|
return `
|
||||||
|
<div class="diff-metadata">
|
||||||
|
<span class="timestamp">${new Date(metadata.timestamp).toLocaleString()}</span>
|
||||||
|
<span class="user">${metadata.user || 'Anonymous'}</span>
|
||||||
|
<span class="change-type">${this.formatChangeType(metadata.change_type)}</span>
|
||||||
|
${metadata.reason ? `<span class="reason">${metadata.reason}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderControls() {
|
||||||
|
return `
|
||||||
|
<div class="diff-controls">
|
||||||
|
<button class="btn-view-mode" data-mode="side-by-side">Side by Side</button>
|
||||||
|
<button class="btn-view-mode" data-mode="inline">Inline</button>
|
||||||
|
<button class="btn-collapse-all">Collapse All</button>
|
||||||
|
<button class="btn-expand-all">Expand All</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNavigation(navigation) {
|
||||||
|
return `
|
||||||
|
<div class="diff-navigation">
|
||||||
|
${navigation.prev_id ? `<button class="btn-prev" data-id="${navigation.prev_id}">Previous</button>` : ''}
|
||||||
|
${navigation.next_id ? `<button class="btn-next" data-id="${navigation.next_id}">Next</button>` : ''}
|
||||||
|
<span class="position-indicator">Change ${navigation.current_position}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLineNumbers(numbers) {
|
||||||
|
return numbers.map(num => `<span class="line-number">${num}</span>`).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(`<span class="diff-removed">${this.escapeHtml(oldLines[i])}</span>`);
|
||||||
|
}
|
||||||
|
if (newLines[i]) {
|
||||||
|
diffLines.push(`<span class="diff-added">${this.escapeHtml(newLines[i])}</span>`);
|
||||||
|
}
|
||||||
|
} 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,
|
||||||
|
'<span class="json-key">"$1":</span>'
|
||||||
|
);
|
||||||
|
} 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, '<span class="keyword">$1</span>')
|
||||||
|
.replace(/(["'])(.*?)\1/g, '<span class="string">$1$2$1</span>')
|
||||||
|
.replace(/#.*/g, '<span class="comment">$&</span>');
|
||||||
|
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 => `
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-header">
|
||||||
|
<span class="comment-author">${comment.author}</span>
|
||||||
|
<span class="comment-date">${new Date(comment.date).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">${comment.content}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
element.appendChild(threadElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiffViewer;
|
||||||
285
static/js/components/inline-comment-panel.js
Normal file
285
static/js/components/inline-comment-panel.js
Normal file
@@ -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 = `
|
||||||
|
<div class="comment-panel">
|
||||||
|
<div class="comment-header">
|
||||||
|
<div class="thread-info">
|
||||||
|
<span class="anchor-info">${this.formatAnchor(this.thread.anchor)}</span>
|
||||||
|
${this.thread.is_resolved ?
|
||||||
|
`<span class="resolution-badge">
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Resolved
|
||||||
|
</span>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
${this.canResolve && !this.thread.is_resolved ?
|
||||||
|
`<button class="resolve-button" onclick="this.resolveThread()">
|
||||||
|
Resolve Thread
|
||||||
|
</button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="comments-container">
|
||||||
|
${this.renderComments(this.thread.comments)}
|
||||||
|
</div>
|
||||||
|
${!this.thread.is_resolved ?
|
||||||
|
`<div class="reply-form">
|
||||||
|
<textarea
|
||||||
|
class="reply-input"
|
||||||
|
placeholder="Write a reply..."
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="reply-button" onclick="this.submitReply()">
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.attachEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderComments(comments) {
|
||||||
|
return comments.map(comment => `
|
||||||
|
<div class="comment ${comment.parent_comment ? 'reply' : ''}" data-comment-id="${comment.id}">
|
||||||
|
<div class="comment-author">
|
||||||
|
<img src="${comment.author.avatar_url || '/static/images/default-avatar.png'}"
|
||||||
|
alt="${comment.author.username}"
|
||||||
|
class="author-avatar"
|
||||||
|
/>
|
||||||
|
<span class="author-name">${comment.author.username}</span>
|
||||||
|
<span class="comment-date">${this.formatDate(comment.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">
|
||||||
|
${this.formatCommentContent(comment.content)}
|
||||||
|
</div>
|
||||||
|
${this.renderCommentActions(comment)}
|
||||||
|
${comment.replies ?
|
||||||
|
`<div class="replies">
|
||||||
|
${this.renderComments(comment.replies)}
|
||||||
|
</div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCommentActions(comment) {
|
||||||
|
if (this.thread.is_resolved) return '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="comment-actions">
|
||||||
|
<button class="action-button" onclick="this.showReplyForm('${comment.id}')">
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
${comment.author.id === this.currentUser.id ?
|
||||||
|
`<button class="action-button" onclick="this.editComment('${comment.id}')">
|
||||||
|
Edit
|
||||||
|
</button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatCommentContent(content) {
|
||||||
|
// Replace @mentions with styled spans
|
||||||
|
content = content.replace(/@(\w+)/g, '<span class="mention">@$1</span>');
|
||||||
|
|
||||||
|
// Convert URLs to links
|
||||||
|
content = content.replace(
|
||||||
|
/(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g,
|
||||||
|
'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<textarea
|
||||||
|
class="reply-input"
|
||||||
|
placeholder="Write a reply..."
|
||||||
|
rows="2"
|
||||||
|
></textarea>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="cancel-button" onclick="this.hideReplyForm('${commentId}')">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="reply-button" onclick="this.submitNestedReply('${commentId}')">
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<textarea class="edit-input" rows="2">${content}</textarea>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="cancel-button" onclick="this.cancelEdit('${commentId}')">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button class="save-button" onclick="this.saveEdit('${commentId}')">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
314
static/js/components/version-comparison.js
Normal file
314
static/js/components/version-comparison.js
Normal file
@@ -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 = `
|
||||||
|
<div class="timeline-track">
|
||||||
|
${this._renderTimelineDots(sortedVersions)}
|
||||||
|
</div>
|
||||||
|
<div class="timeline-labels">
|
||||||
|
${this._renderTimelineLabels(sortedVersions)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderTimelineDots(versions) {
|
||||||
|
return versions.map(version => `
|
||||||
|
<div class="timeline-point ${this.selectedVersions.has(version.name) ? 'selected' : ''}"
|
||||||
|
data-version="${version.name}"
|
||||||
|
style="--impact-score: ${version.comparison_metadata.impact_score || 0}"
|
||||||
|
onclick="this._toggleVersionSelection('${version.name}')">
|
||||||
|
<div class="point-indicator"></div>
|
||||||
|
${this._renderImpactIndicator(version)}
|
||||||
|
</div>
|
||||||
|
`).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 `
|
||||||
|
<div class="impact-indicator"
|
||||||
|
style="width: ${size}px; height: ${size}px"
|
||||||
|
title="Impact Score: ${Math.round(impact * 100)}%">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderTimelineLabels(versions) {
|
||||||
|
return versions.map(version => `
|
||||||
|
<div class="timeline-label" data-version="${version.name}">
|
||||||
|
<div class="version-name">${version.name}</div>
|
||||||
|
<div class="version-date">
|
||||||
|
${new Date(version.created_at).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
const selectedVersionsArray = Array.from(this.selectedVersions);
|
||||||
|
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="version-comparison-tool">
|
||||||
|
<div class="comparison-header">
|
||||||
|
<h3>Version Comparison</h3>
|
||||||
|
<div class="selected-versions">
|
||||||
|
${this._renderSelectedVersions(selectedVersionsArray)}
|
||||||
|
</div>
|
||||||
|
<div class="comparison-actions">
|
||||||
|
${this._renderActionButtons(selectedVersionsArray)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.timeline.outerHTML}
|
||||||
|
|
||||||
|
<div class="comparison-content">
|
||||||
|
${this._renderComparisonContent(selectedVersionsArray)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.attachEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderSelectedVersions(selectedVersions) {
|
||||||
|
return selectedVersions.map((version, index) => `
|
||||||
|
<div class="selected-version">
|
||||||
|
<span class="version-label">Version ${index + 1}:</span>
|
||||||
|
<span class="version-value">${version}</span>
|
||||||
|
<button class="remove-version" onclick="this._removeVersion('${version}')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderActionButtons(selectedVersions) {
|
||||||
|
const canCompare = selectedVersions.length >= 2;
|
||||||
|
const canRollback = selectedVersions.length === 1;
|
||||||
|
|
||||||
|
return `
|
||||||
|
${canCompare ? `
|
||||||
|
<button class="compare-button" onclick="this._handleCompare()">
|
||||||
|
Compare Versions
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
${canRollback ? `
|
||||||
|
<button class="rollback-button" onclick="this._handleRollback()">
|
||||||
|
Rollback to Version
|
||||||
|
</button>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderComparisonContent(selectedVersions) {
|
||||||
|
if (selectedVersions.length < 2) {
|
||||||
|
return `
|
||||||
|
<div class="comparison-placeholder">
|
||||||
|
Select at least two versions to compare
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="comparison-results">
|
||||||
|
<div class="results-loading">
|
||||||
|
Computing differences...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_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 = `
|
||||||
|
<div class="results-content">
|
||||||
|
${results.map(diff => this._renderDiffSection(diff)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderDiffSection(diff) {
|
||||||
|
return `
|
||||||
|
<div class="diff-section">
|
||||||
|
<div class="diff-header">
|
||||||
|
<h4>Changes: ${diff.version1} → ${diff.version2}</h4>
|
||||||
|
<div class="diff-stats">
|
||||||
|
<span class="computation-time">
|
||||||
|
Computed in ${diff.computation_time.toFixed(2)}s
|
||||||
|
</span>
|
||||||
|
<span class="impact-score">
|
||||||
|
Impact Score: ${Math.round(diff.impact_score * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="changes-list">
|
||||||
|
${this._renderChanges(diff.changes)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderChanges(changes) {
|
||||||
|
return changes.map(change => `
|
||||||
|
<div class="change-item">
|
||||||
|
<div class="change-header">
|
||||||
|
<span class="change-type">${change.type}</span>
|
||||||
|
<span class="change-file">${change.file}</span>
|
||||||
|
</div>
|
||||||
|
<div class="change-content">
|
||||||
|
<div class="old-value">
|
||||||
|
<div class="value-header">Previous</div>
|
||||||
|
<pre>${this._escapeHtml(change.old_value)}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="new-value">
|
||||||
|
<div class="value-header">New</div>
|
||||||
|
<pre>${this._escapeHtml(change.new_value)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).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;
|
||||||
190
static/js/components/virtual-scroller.js
Normal file
190
static/js/components/virtual-scroller.js
Normal file
@@ -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;
|
||||||
@@ -124,6 +124,289 @@ class VersionControl {
|
|||||||
.then(response => response.json());
|
.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 = `
|
||||||
|
<svg class="lock-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<div class="lock-info">
|
||||||
|
<span class="user">${lockStatus.user}</span>
|
||||||
|
<span class="expiry">Expires: ${expiryDate.toLocaleString()}</span>
|
||||||
|
${lockStatus.reason ? `<span class="reason">${lockStatus.reason}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
statusElement.className = 'lock-status unlocked';
|
||||||
|
statusElement.innerHTML = `
|
||||||
|
<svg class="lock-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M10 2a5 5 0 00-5 5v2a2 2 0 00-2 2v5a2 2 0 002 2h10a2 2 0 002-2v-5a2 2 0 00-2-2H7V7a3 3 0 015.905-.75 1 1 0 001.937-.5A5.002 5.002 0 0010 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span>Unlocked</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = `
|
||||||
|
<button class="lock-button unlock" onclick="window.versionControl.releaseLock('${branchName}')">
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M10 2a5 5 0 00-5 5v2a2 2 0 00-2 2v5a2 2 0 002 2h10a2 2 0 002-2v-5a2 2 0 00-2-2H7V7a3 3 0 015.905-.75 1 1 0 001.937-.5A5.002 5.002 0 0010 2z"/>
|
||||||
|
</svg>
|
||||||
|
Unlock Branch
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
controlsElement.innerHTML = `
|
||||||
|
<button class="lock-button lock" onclick="window.versionControl.acquireLock('${branchName}')">
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Lock Branch
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
getCsrfToken() {
|
getCsrfToken() {
|
||||||
return document.querySelector('[name=csrfmiddlewaretoken]').value;
|
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
|
// Initialize version control
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
window.versionControl = new VersionControl();
|
window.versionControl = new VersionControl();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user