Add comment and reply functionality with preview and notification templates

This commit is contained in:
pacnpal
2025-02-07 13:13:49 -05:00
parent c083f54afb
commit a07c882d42
30 changed files with 5153 additions and 383 deletions

View 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}"

View 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')
})

View File

@@ -5,6 +5,8 @@ from django.utils import timezone
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import AbstractUser
from collections import Counter
import json
from .models import VersionBranch, VersionTag, ChangeSet
UserModel = TypeVar('UserModel', bound=AbstractUser)
@@ -73,6 +75,142 @@ class BranchManager:
queryset = queryset.filter(is_active=True)
return list(queryset)
@transaction.atomic
def acquire_lock(
self,
branch: VersionBranch,
user: UserModel,
duration: int = 48,
reason: str = ""
) -> bool:
"""
Acquire a lock on a branch
Args:
branch: The branch to lock
user: User acquiring the lock
duration: Lock duration in hours (default 48)
reason: Reason for locking
Returns:
bool: True if lock acquired, False if already locked
"""
# Check if branch is already locked
if branch.lock_status:
expires = timezone.datetime.fromisoformat(branch.lock_status['expires'])
if timezone.now() < expires:
return False
# Set lock
expiry = timezone.now() + timezone.timedelta(hours=duration)
branch.lock_status = {
'user': user.id,
'expires': expiry.isoformat(),
'reason': reason
}
# Record in history
branch.lock_history.append({
'user': user.id,
'action': 'lock',
'timestamp': timezone.now().isoformat(),
'reason': reason,
'expires': expiry.isoformat()
})
branch.save()
return True
@transaction.atomic
def release_lock(
self,
branch: VersionBranch,
user: UserModel,
force: bool = False
) -> bool:
"""
Release a lock on a branch
Args:
branch: The branch to unlock
user: User releasing the lock
force: Whether to force unlock (requires permissions)
Returns:
bool: True if lock released, False if not locked or unauthorized
"""
if not branch.lock_status:
return False
locked_by = branch.lock_status.get('user')
if not locked_by:
return False
# Check authorization
if not force and locked_by != user.id:
if not user.has_perm('history_tracking.force_unlock_branch'):
return False
# Record in history
branch.lock_history.append({
'user': user.id,
'action': 'unlock',
'timestamp': timezone.now().isoformat(),
'forced': force
})
# Clear lock
branch.lock_status = {}
branch.save()
return True
def check_lock(self, branch: VersionBranch) -> Dict[str, Any]:
"""
Check the lock status of a branch
Args:
branch: The branch to check
Returns:
dict: Lock status information
"""
if not branch.lock_status:
return {'locked': False}
expires = timezone.datetime.fromisoformat(branch.lock_status['expires'])
if timezone.now() >= expires:
# Lock has expired
branch.lock_status = {}
branch.save()
return {'locked': False}
return {
'locked': True,
'user': User.objects.get(id=branch.lock_status['user']),
'expires': expires,
'reason': branch.lock_status.get('reason', '')
}
def get_lock_history(
self,
branch: VersionBranch,
limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Get the lock history for a branch
Args:
branch: The branch to get history for
limit: Optional limit on number of entries
Returns:
list: Lock history entries
"""
history = branch.lock_history
if limit:
history = history[-limit:]
# Enhance history with user objects
for entry in history:
try:
entry['user_obj'] = User.objects.get(id=entry['user'])
except User.DoesNotExist:
entry['user_obj'] = None
return history
class ChangeTracker:
"""Tracks and manages changes across the system"""
@@ -114,6 +252,189 @@ class ChangeTracker:
"""Get all changes in a branch ordered by creation time"""
return list(ChangeSet.objects.filter(branch=branch).order_by('created_at'))
def compute_enhanced_diff(
self,
version1: Any,
version2: Any,
syntax_detect: bool = True
) -> Dict[str, Any]:
"""
Return structured diff with syntax metadata
Args:
version1: First version to compare
version2: Second version to compare
syntax_detect: Whether to detect syntax types
Returns:
Dict containing structured diff with metadata
"""
if not hasattr(version1, 'history') or not hasattr(version2, 'history'):
raise ValueError("Both versions must be history-tracked models")
# Get historical records
v1_history = version1.history.first()
v2_history = version2.history.first()
if not (v1_history and v2_history):
raise ValueError("No historical records found")
changes = {}
# Compare fields and detect syntax
for field in v2_history._meta.fields:
field_name = field.name
if field_name in [
'history_id', 'history_date', 'history_type',
'history_user_id', 'history_change_reason'
]:
continue
old_value = getattr(v1_history, field_name)
new_value = getattr(v2_history, field_name)
if old_value != new_value:
field_type = field.get_internal_type()
syntax_type = self._detect_syntax(field_type, old_value) if syntax_detect else 'text'
changes[field_name] = {
'old': str(old_value),
'new': str(new_value),
'type': field_type,
'syntax': syntax_type,
'line_numbers': self._compute_line_numbers(old_value, new_value),
'metadata': {
'comment_anchor_id': f"{v2_history.history_id}_{field_name}",
'field_type': field_type,
'content_type': v2_history._meta.model_name
}
}
# Calculate impact metrics
impact_metrics = self._calculate_impact_metrics(changes)
return {
'changes': changes,
'metadata': {
'version1_id': v1_history.history_id,
'version2_id': v2_history.history_id,
'timestamp': timezone.now().isoformat(),
'impact_score': impact_metrics['impact_score'],
'stats': impact_metrics['stats'],
'performance': {
'syntax_detection': syntax_detect,
'computed_at': timezone.now().isoformat()
}
}
}
def _detect_syntax(self, field_type: str, value: Any) -> str:
"""
Detect syntax type for field content
Args:
field_type: Django field type
value: Field value
Returns:
Detected syntax type
"""
if field_type in ['TextField', 'CharField']:
# Try to detect if it's code
if isinstance(value, str):
if value.startswith('def ') or value.startswith('class '):
return 'python'
if value.startswith('{') or value.startswith('['):
try:
json.loads(value)
return 'json'
except:
pass
if value.startswith('<!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:
"""Handles merge operations and conflict resolution"""

View File

@@ -1,9 +1,11 @@
# history_tracking/mixins.py
from django.db import models
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
class HistoricalChangeMixin(models.Model):
"""Mixin for historical models to track changes"""
comments = GenericRelation('CommentThread', related_query_name='historical_record')
id = models.BigIntegerField(db_index=True, auto_created=True, blank=True)
history_date = models.DateTimeField()
history_id = models.AutoField(primary_key=True)
@@ -33,6 +35,7 @@ class HistoricalChangeMixin(models.Model):
@property
def diff_against_previous(self):
"""Get enhanced diff with syntax highlighting and metadata"""
prev_record = self.prev_record
if not prev_record:
return {}
@@ -54,11 +57,69 @@ class HistoricalChangeMixin(models.Model):
old_value = getattr(prev_record, field)
new_value = getattr(self, field)
if old_value != new_value:
changes[field] = {"old": str(old_value), "new": str(new_value)}
field_type = self._meta.get_field(field).get_internal_type()
syntax_type = self._get_syntax_type(field_type)
changes[field] = {
"old": str(old_value),
"new": str(new_value),
"syntax_type": syntax_type,
"metadata": {
"field_type": field_type,
"comment_anchor_id": f"{self.history_id}_{field}",
"line_numbers": self._compute_line_numbers(old_value, new_value)
}
}
except AttributeError:
continue
return changes
def _get_syntax_type(self, field_type):
"""Map Django field types to syntax highlighting types"""
syntax_map = {
'TextField': 'text',
'JSONField': 'json',
'FileField': 'path',
'ImageField': 'path',
'URLField': 'url',
'EmailField': 'email',
'CodeField': 'python' # Custom field type for code
}
return syntax_map.get(field_type, 'text')
def _compute_line_numbers(self, old_value, new_value):
"""Compute line numbers for diff navigation"""
old_lines = str(old_value).count('\n') + 1
new_lines = str(new_value).count('\n') + 1
return {
"old": list(range(1, old_lines + 1)),
"new": list(range(1, new_lines + 1))
}
def get_structured_diff(self, other_version=None):
"""Get structured diff between two versions with enhanced metadata"""
compare_to = other_version or self.prev_record
if not compare_to:
return None
diff_data = self.diff_against_previous
return {
"changes": diff_data,
"metadata": {
"timestamp": self.history_date.isoformat(),
"user": self.history_user_display,
"change_type": self.history_type,
"reason": self.history_change_reason,
"performance": {
"computation_time": None # To be filled by frontend
}
},
"navigation": {
"next_id": None, # To be filled by frontend
"prev_id": None, # To be filled by frontend
"current_position": None # To be filled by frontend
}
}
@property
def history_user_display(self):
"""Get a display name for the history user"""

View File

@@ -1,6 +1,6 @@
from django.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.auth import get_user_model
from simple_history.models import HistoricalRecords
from .mixins import HistoricalChangeMixin
@@ -60,6 +60,14 @@ class VersionBranch(models.Model):
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
metadata = models.JSONField(default=dict, blank=True)
is_active = models.BooleanField(default=True)
lock_status = models.JSONField(
default=dict,
help_text="Current lock status: {user: ID, expires: datetime, reason: str}"
)
lock_history = models.JSONField(
default=list,
help_text="History of lock operations: [{user: ID, action: lock/unlock, timestamp: datetime, reason: str}]"
)
class Meta:
ordering = ['-created_at']
@@ -91,6 +99,10 @@ class VersionTag(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
metadata = models.JSONField(default=dict, blank=True)
comparison_metadata = models.JSONField(
default=dict,
help_text="Stores diff statistics and comparison results"
)
class Meta:
ordering = ['-created_at']
@@ -104,6 +116,74 @@ class VersionTag(models.Model):
def __str__(self) -> str:
return f"{self.name} ({self.branch.name})"
class CommentThread(models.Model):
"""Represents a thread of comments on a historical record"""
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
content_object = GenericForeignKey('content_type', 'object_id')
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='created_threads')
anchor = models.JSONField(
default=dict,
help_text="Anchoring information: {line_start: int, line_end: int, file_path: str}"
)
is_resolved = models.BooleanField(default=False)
resolved_by = models.ForeignKey(
User,
on_delete=models.SET_NULL,
null=True,
related_name='resolved_threads'
)
resolved_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['content_type', 'object_id']),
models.Index(fields=['created_at']),
models.Index(fields=['is_resolved']),
]
def __str__(self) -> str:
return f"Comment Thread {self.pk} on {self.content_type}"
class Comment(models.Model):
"""Individual comment within a thread"""
thread = models.ForeignKey(CommentThread, on_delete=models.CASCADE, related_name='comments')
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
mentioned_users = models.ManyToManyField(
User,
related_name='mentioned_in_comments',
blank=True
)
parent_comment = models.ForeignKey(
'self',
on_delete=models.CASCADE,
null=True,
blank=True,
related_name='replies'
)
class Meta:
ordering = ['created_at']
def __str__(self) -> str:
return f"Comment {self.pk} by {self.author}"
def extract_mentions(self) -> None:
"""Extract @mentions from comment content and update mentioned_users"""
# Simple @username extraction - could be enhanced with regex
mentioned = [
word[1:] for word in self.content.split()
if word.startswith('@') and len(word) > 1
]
if mentioned:
users = User.objects.filter(username__in=mentioned)
self.mentioned_users.set(users)
class ChangeSet(models.Model):
"""Groups related changes together for atomic version control operations"""
branch = models.ForeignKey(VersionBranch, on_delete=models.CASCADE, related_name='changesets')
@@ -115,12 +195,41 @@ class ChangeSet(models.Model):
status = models.CharField(
max_length=20,
choices=[
('pending', 'Pending'),
('draft', 'Draft'),
('pending_approval', 'Pending Approval'),
('approved', 'Approved'),
('rejected', 'Rejected'),
('applied', 'Applied'),
('failed', 'Failed'),
('reverted', 'Reverted')
],
default='pending'
default='draft'
)
approval_state = models.JSONField(
default=list,
help_text="List of approval stages and their status"
)
approval_history = models.JSONField(
default=list,
help_text="History of approval actions and decisions"
)
required_approvers = models.ManyToManyField(
User,
related_name='pending_approvals',
blank=True
)
approval_policy = models.CharField(
max_length=20,
choices=[
('sequential', 'Sequential'),
('parallel', 'Parallel')
],
default='sequential'
)
approval_deadline = models.DateTimeField(
null=True,
blank=True,
help_text="Optional deadline for approvals"
)
# Instead of directly relating to HistoricalRecord, use GenericForeignKey

View 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}"

View 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)

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{% if content %}
<div class="comment-preview-content">
<h6>Preview:</h6>
<div class="preview-text">{{ content }}</div>
</div>
{% endif %}

View File

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

View File

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

View File

@@ -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>

View File

@@ -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">&laquo; 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 &raquo;</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 %}

View File

@@ -1,22 +1,50 @@
from django.urls import path
from . import views
from . import views, htmx_views
app_name = 'history'
app_name = 'history_tracking'
urlpatterns = [
# Main VCS interface
path('vcs/', views.VersionControlPanel.as_view(), name='vcs-panel'),
# Main page views
path('compare/',
views.version_comparison,
name='version_comparison'
),
path('approval-status/<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
path('vcs/branches/', views.BranchListView.as_view(), name='branch-list'),
path('vcs/branches/create/', views.BranchCreateView.as_view(), name='branch-create'),
# History views
path('vcs/history/', views.HistoryView.as_view(), name='history-view'),
# Merge operations
path('vcs/merge/', views.MergeView.as_view(), name='merge-view'),
# Tag operations
path('vcs/tags/create/', views.TagCreateView.as_view(), name='tag-create'),
# HTMX endpoints
path('htmx/comments/get/',
htmx_views.get_comments,
name='get_comments'
),
path('htmx/comments/preview/',
htmx_views.preview_comment,
name='preview_comment'
),
path('htmx/comments/add/',
htmx_views.add_comment,
name='add_comment'
),
path('htmx/approve/<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'
),
]

View File

@@ -1,238 +1,144 @@
from django.views.generic import TemplateView, View
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ValidationError
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db import transaction
from django.http import HttpRequest, HttpResponse
from django.utils import timezone
from django.contrib import messages
from django.views.decorators.http import require_http_methods
from django.core.exceptions import PermissionDenied
from typing import Dict, Any
from .models import VersionBranch, VersionTag, ChangeSet
from .managers import BranchManager, ChangeTracker, MergeStrategy
from .models import VersionBranch, ChangeSet, VersionTag, CommentThread
from .managers import ChangeTracker
from .comparison import ComparisonEngine
from .state_machine import ApprovalStateMachine
class VersionControlPanel(LoginRequiredMixin, TemplateView):
"""Main version control interface"""
template_name = 'history_tracking/version_control_panel.html'
ITEMS_PER_PAGE = 20
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
branch_manager = BranchManager()
context.update({
'branches': branch_manager.list_branches(),
'current_branch': self.request.GET.get('branch'),
})
return context
class BranchListView(LoginRequiredMixin, View):
"""HTMX view for branch list"""
@login_required
def version_comparison(request: HttpRequest) -> HttpResponse:
"""View for comparing different versions"""
versions = VersionTag.objects.all().order_by('-created_at')
def get(self, request):
branch_manager = BranchManager()
branches = branch_manager.list_branches()
content = render_to_string(
'history_tracking/components/branch_list.html',
{'branches': branches},
request=request
)
return HttpResponse(content)
class HistoryView(LoginRequiredMixin, View):
"""HTMX view for change history"""
version1_id = request.GET.get('version1')
version2_id = request.GET.get('version2')
page_number = request.GET.get('page', 1)
def get(self, request):
branch_name = request.GET.get('branch')
if not branch_name:
return HttpResponse("No branch selected")
diff_result = None
if version1_id and version2_id:
try:
version1 = get_object_or_404(VersionTag, id=version1_id)
version2 = get_object_or_404(VersionTag, id=version2_id)
branch = get_object_or_404(VersionBranch, name=branch_name)
tracker = ChangeTracker()
changes = tracker.get_changes(branch)
content = render_to_string(
'history_tracking/components/history_view.html',
{'changes': changes},
request=request
)
return HttpResponse(content)
class MergeView(LoginRequiredMixin, View):
"""HTMX view for merge operations"""
def get(self, request):
source = request.GET.get('source')
target = request.GET.get('target')
if not (source and target):
return HttpResponse("Source and target branches required")
# Get comparison results
engine = ComparisonEngine()
diff_result = engine.compute_enhanced_diff(version1, version2)
content = render_to_string(
'history_tracking/components/merge_panel.html',
# Paginate changes
paginator = Paginator(diff_result['changes'], ITEMS_PER_PAGE)
diff_result['changes'] = paginator.get_page(page_number)
# Add comments to changes
for change in diff_result['changes']:
anchor_id = change['metadata']['comment_anchor_id']
change['comments'] = CommentThread.objects.filter(
anchor__contains={'id': anchor_id}
).prefetch_related('comments')
except Exception as e:
messages.error(request, f"Error comparing versions: {str(e)}")
context = {
'versions': versions,
'selected_version1': version1_id,
'selected_version2': version2_id,
'diff_result': diff_result
}
return render(request, 'history_tracking/version_comparison.html', context)
@login_required
@require_http_methods(["POST"])
@transaction.atomic
def submit_for_approval(request: HttpRequest, changeset_id: int) -> HttpResponse:
"""Submit a changeset for approval"""
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
if not request.user.has_perm('history_tracking.submit_for_approval'):
raise PermissionDenied("You don't have permission to submit changes for approval")
try:
# Initialize approval workflow
state_machine = ApprovalStateMachine(changeset)
stages_config = [
{
'source': source,
'target': target
'name': 'Technical Review',
'required_roles': ['tech_reviewer']
},
request=request
)
return HttpResponse(content)
@transaction.atomic
def post(self, request):
source_name = request.POST.get('source')
target_name = request.POST.get('target')
{
'name': 'Final Approval',
'required_roles': ['approver']
}
]
if not (source_name and target_name):
return JsonResponse({
'error': 'Source and target branches required'
}, status=400)
try:
source = get_object_or_404(VersionBranch, name=source_name)
target = get_object_or_404(VersionBranch, name=target_name)
branch_manager = BranchManager()
success, conflicts = branch_manager.merge_branches(
source=source,
target=target,
user=request.user
)
if success:
content = render_to_string(
'history_tracking/components/merge_success.html',
{'source': source, 'target': target},
request=request
)
return HttpResponse(content)
else:
content = render_to_string(
'history_tracking/components/merge_conflicts.html',
{
'source': source,
'target': target,
'conflicts': conflicts
},
request=request
)
return HttpResponse(content)
except ValidationError as e:
return JsonResponse({'error': str(e)}, status=400)
except Exception as e:
return JsonResponse(
{'error': 'Merge failed. Please try again.'},
status=500
)
state_machine.initialize_workflow(stages_config)
changeset.status = 'pending_approval'
changeset.save()
messages.success(request, "Changes submitted for approval successfully")
except Exception as e:
messages.error(request, f"Error submitting for approval: {str(e)}")
return render(request, 'history_tracking/approval_status.html', {
'changeset': changeset
})
class BranchCreateView(LoginRequiredMixin, View):
"""HTMX view for branch creation"""
@login_required
def approval_status(request: HttpRequest, changeset_id: int) -> HttpResponse:
"""View approval status of a changeset"""
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
state_machine = ApprovalStateMachine(changeset)
current_stage = state_machine.get_current_stage()
def get(self, request):
content = render_to_string(
'history_tracking/components/branch_create.html',
request=request
)
return HttpResponse(content)
context = {
'changeset': changeset,
'current_stage': current_stage,
'can_approve': state_machine.can_user_approve(request.user),
'pending_approvers': state_machine.get_pending_approvers()
}
@transaction.atomic
def post(self, request):
name = request.POST.get('name')
parent_name = request.POST.get('parent')
if not name:
return JsonResponse({'error': 'Branch name required'}, status=400)
try:
branch_manager = BranchManager()
parent = None
if parent_name:
parent = get_object_or_404(VersionBranch, name=parent_name)
branch = branch_manager.create_branch(
name=name,
parent=parent,
user=request.user
)
content = render_to_string(
'history_tracking/components/branch_item.html',
{'branch': branch},
request=request
)
return HttpResponse(content)
except ValidationError as e:
return JsonResponse({'error': str(e)}, status=400)
except Exception as e:
return JsonResponse(
{'error': 'Branch creation failed. Please try again.'},
status=500
)
return render(request, 'history_tracking/approval_status.html', context)
class TagCreateView(LoginRequiredMixin, View):
"""HTMX view for version tagging"""
@login_required
@require_http_methods(["POST"])
@transaction.atomic
def approve_changes(request: HttpRequest, changeset_id: int) -> HttpResponse:
"""Submit an approval decision"""
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
state_machine = ApprovalStateMachine(changeset)
def get(self, request):
branch_name = request.GET.get('branch')
if not branch_name:
return HttpResponse("Branch required")
content = render_to_string(
'history_tracking/components/tag_create.html',
{'branch_name': branch_name},
request=request
)
return HttpResponse(content)
@transaction.atomic
def post(self, request):
name = request.POST.get('name')
branch_name = request.POST.get('branch')
try:
decision = request.POST.get('decision', 'approve')
comment = request.POST.get('comment', '')
stage_id = request.POST.get('stage_id')
if not (name and branch_name):
return JsonResponse(
{'error': 'Tag name and branch required'},
status=400
)
success = state_machine.submit_approval(
user=request.user,
decision=decision,
comment=comment,
stage_id=stage_id
)
if success:
messages.success(request, f"Successfully {decision}d changes")
else:
messages.error(request, "Failed to submit approval")
try:
branch = get_object_or_404(VersionBranch, name=branch_name)
# Get latest historical record for the branch
latest_change = ChangeSet.objects.filter(
branch=branch,
status='applied'
).latest('created_at')
if not latest_change:
return JsonResponse(
{'error': 'No changes to tag'},
status=400
)
tag = VersionTag.objects.create(
name=name,
branch=branch,
historical_record=latest_change.historical_records.latest('history_date'),
created_by=request.user,
metadata={
'tagged_at': timezone.now().isoformat(),
'changeset': latest_change.pk
}
)
content = render_to_string(
'history_tracking/components/tag_item.html',
{'tag': tag},
request=request
)
return HttpResponse(content)
except ValidationError as e:
return JsonResponse({'error': str(e)}, status=400)
except Exception as e:
return JsonResponse(
{'error': 'Tag creation failed. Please try again.'},
status=500
)
except Exception as e:
messages.error(request, f"Error processing approval: {str(e)}")
return render(request, 'history_tracking/approval_status.html', {
'changeset': changeset
})