Revert "Add version control system functionality with branch management, history tracking, and merge operations"

This reverts commit f3d28817a5.
This commit is contained in:
pacnpal
2025-02-08 17:37:30 -05:00
parent 03f9df4bab
commit 71b73522ae
125 changed files with 617 additions and 15830 deletions

View File

@@ -1,241 +0,0 @@
# Version Control System
## Overview
A comprehensive version control system for Django models that provides branching, merging, and change tracking capabilities with optimized performance through batch processing and caching.
## Requirements
### System Requirements
- Python 3.8+
- Django 4.0+
- Redis 6.0+ (for caching)
- PostgreSQL 12+ (recommended for database)
### Python Dependencies
```
django-simple-history>=3.0.0
redis>=4.0.0
```
## Installation
1. Add 'history_tracking' to your INSTALLED_APPS:
```python
INSTALLED_APPS = [
...
'history_tracking',
]
```
2. Configure Redis connection in settings.py:
```python
# Uses existing Redis configuration if available
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379/1", # Adjust as needed
}
}
# Version control specific settings
VERSION_CONTROL = {
'CACHE_PREFIX': 'vc_', # Prefix for cache keys
'BATCH_SIZE': 100, # Default batch size for operations
'MAX_WORKERS': 4, # Maximum parallel workers
'CACHE_DURATIONS': { # Cache durations in seconds
'BRANCH': 3600, # 1 hour
'CHANGE': 1800, # 30 minutes
'HISTORY': 86400, # 24 hours
}
}
```
3. Run migrations:
```bash
python manage.py migrate history_tracking
```
## Usage
### Making Models Version-Controlled
1. Inherit from HistoricalModel:
```python
from history_tracking.models import HistoricalModel
class YourModel(HistoricalModel):
name = models.CharField(max_length=255)
description = models.TextField()
```
2. The model will automatically track:
- All field changes
- Who made changes
- When changes were made
- Which branch changes were made in
### Working with Branches
```python
from history_tracking.models import VersionBranch
# Create a new branch
branch = VersionBranch.objects.create(
name="feature/new-content",
metadata={"type": "feature"}
)
# Make changes in branch context
from history_tracking.context_processors import branch_context
with branch_context(branch):
your_model.save() # Changes are tracked in the branch
```
### Batch Operations
For handling multiple changes efficiently:
```python
from history_tracking.batch import BatchOperation
# Create batch operation
batch = BatchOperation(max_workers=4)
# Add changes to batch
for item in items:
batch.add_change(item, {'field': 'new_value'})
# Process changes (parallel or sequential)
results = batch.commit(parallel=True)
```
### Using the Queue System
For large-scale operations:
```python
from history_tracking.batch import VersionControlQueue
# Create queue with custom batch size
queue = VersionControlQueue(batch_size=100)
# Queue changes
for item in large_dataset:
queue.queue_change(item, {'field': 'new_value'})
# Process queue
results = queue.process_queue(parallel=True)
```
## Cache Management
The system automatically caches:
- Branch information
- Change details
- Version history
Cache invalidation is handled automatically, but you can manually invalidate:
```python
from history_tracking.caching import VersionHistoryCache
# Invalidate specific caches
VersionHistoryCache.invalidate_branch(branch_id)
VersionHistoryCache.invalidate_history(content_type_id, object_id)
# Invalidate all version control caches
VersionHistoryCache.invalidate_all()
```
## Monitoring
The system includes built-in monitoring:
```python
from history_tracking.monitoring import VersionControlMetrics
# Collect system metrics
VersionControlMetrics.collect_system_metrics()
VersionControlMetrics.collect_performance_metrics()
```
Metrics are logged and can be viewed:
- In application logs
- Through the Django admin interface
- Via monitoring endpoints (if configured)
## Performance Considerations
The system is optimized for:
- Batch processing of changes
- Efficient caching of frequently accessed data
- Parallel processing capabilities
- Minimal database queries
For large-scale operations:
- Use batch processing
- Enable parallel processing when appropriate
- Configure cache durations based on your needs
- Monitor performance metrics
## Security
The system integrates with Django's authentication and permissions:
- All changes are tracked with user information
- Branch access can be controlled
- Merge operations can require approval
## Templates
The system includes template tags for displaying version control information:
```html
{% load version_control_tags %}
{% version_status object %}
{% branch_selector %}
{% history_list object %}
```
## API Endpoints
Documentation for API endpoints can be found in `docs/version_control_api.md`.
## Database Considerations
The system uses your existing Django database configuration and creates these main tables:
- history_tracking_versionbranch
- history_tracking_changeset
- history_tracking_versiontag
- history_tracking_commentthread
Plus historical tables for each tracked model.
## Troubleshooting
Common issues and solutions:
1. Performance Issues
- Check batch sizes
- Verify cache configuration
- Monitor database queries
- Review parallel processing settings
2. Cache Issues
- Verify Redis connection
- Check cache key conflicts
- Monitor cache hit rates
3. Database Issues
- Check indexing
- Monitor query performance
- Review database connection pool settings
## Contributing
Contributions are welcome! Please read our contributing guidelines and submit pull requests.
## License
This project is licensed under the MIT License - see the LICENSE file for details.

View File

@@ -1,9 +1,26 @@
# history_tracking/apps.py
from django.apps import AppConfig
class HistoryTrackingConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'history_tracking'
default_auto_field = "django.db.models.BigAutoField"
name = "history_tracking"
def ready(self):
"""Register signals when the app is ready"""
from . import signals # Import signals to register them
from django.apps import apps
from .mixins import HistoricalChangeMixin
# Get the Park model
try:
Park = apps.get_model('parks', 'Park')
ParkArea = apps.get_model('parks', 'ParkArea')
# Apply mixin to historical models
if HistoricalChangeMixin not in Park.history.model.__bases__:
Park.history.model.__bases__ = (HistoricalChangeMixin,) + Park.history.model.__bases__
if HistoricalChangeMixin not in ParkArea.history.model.__bases__:
ParkArea.history.model.__bases__ = (HistoricalChangeMixin,) + ParkArea.history.model.__bases__
except LookupError:
# Models might not be loaded yet
pass

View File

@@ -1,195 +0,0 @@
from django.db import transaction
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from typing import List, Dict, Any, Optional
from concurrent.futures import ThreadPoolExecutor
import logging
from .models import VersionBranch, ChangeSet
from .caching import VersionHistoryCache
from .signals import get_current_branch
logger = logging.getLogger('version_control')
class BatchOperation:
"""
Handles batch operations for version control system.
Provides efficient handling of multiple changes and updates.
"""
def __init__(self, max_workers: int = 4):
self.max_workers = max_workers
self.changes: List[Dict[str, Any]] = []
self.error_handler = self.default_error_handler
def default_error_handler(self, error: Exception, item: Dict[str, Any]) -> None:
"""Default error handling for batch operations"""
logger.error(f"Batch operation error: {error}, item: {item}")
raise error
def set_error_handler(self, handler) -> None:
"""Set custom error handler for batch operations"""
self.error_handler = handler
def add_change(self, obj: Any, data: Dict[str, Any], branch: Optional[VersionBranch] = None) -> None:
"""Add a change to the batch"""
content_type = ContentType.objects.get_for_model(obj)
self.changes.append({
'content_type': content_type,
'object_id': obj.pk,
'data': data,
'branch': branch or get_current_branch()
})
@transaction.atomic
def process_change(self, change: Dict[str, Any]) -> ChangeSet:
"""Process a single change in the batch"""
try:
changeset = ChangeSet.objects.create(
branch=change['branch'],
content_type=change['content_type'],
object_id=change['object_id'],
data=change['data'],
status='pending'
)
# Apply the change
changeset.apply()
# Cache the result
VersionHistoryCache.cache_change(changeset.to_dict())
return changeset
except Exception as e:
self.error_handler(e, change)
raise
def process_parallel(self) -> List[ChangeSet]:
"""Process changes in parallel using thread pool"""
results = []
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
future_to_change = {
executor.submit(self.process_change, change): change
for change in self.changes
}
for future in future_to_change:
try:
changeset = future.result()
results.append(changeset)
except Exception as e:
change = future_to_change[future]
self.error_handler(e, change)
return results
@transaction.atomic
def process_sequential(self) -> List[ChangeSet]:
"""Process changes sequentially in a single transaction"""
results = []
for change in self.changes:
try:
changeset = self.process_change(change)
results.append(changeset)
except Exception as e:
self.error_handler(e, change)
return results
def commit(self, parallel: bool = False) -> List[ChangeSet]:
"""Commit all changes in the batch"""
if not self.changes:
return []
start_time = timezone.now()
logger.info(f"Starting batch operation with {len(self.changes)} changes")
try:
results = self.process_parallel() if parallel else self.process_sequential()
duration = (timezone.now() - start_time).total_seconds()
logger.info(
f"Batch operation completed: {len(results)} changes processed in {duration:.2f}s"
)
return results
finally:
self.changes = [] # Clear the batch
class BulkVersionControl:
"""
Handles bulk version control operations for collections of objects.
"""
def __init__(self, model_class, branch: Optional[VersionBranch] = None):
self.model_class = model_class
self.branch = branch or get_current_branch()
self.content_type = ContentType.objects.get_for_model(model_class)
self.batch = BatchOperation()
def prepare_bulk_update(self, objects: List[Any], data: Dict[str, Any]) -> None:
"""Prepare bulk update for multiple objects"""
for obj in objects:
self.batch.add_change(obj, data, self.branch)
def prepare_bulk_delete(self, objects: List[Any]) -> None:
"""Prepare bulk delete for multiple objects"""
for obj in objects:
self.batch.add_change(obj, {'action': 'delete'}, self.branch)
def prepare_bulk_create(self, data_list: List[Dict[str, Any]]) -> None:
"""Prepare bulk create for multiple objects"""
for data in data_list:
# Create temporary object for content type
temp_obj = self.model_class()
self.batch.add_change(temp_obj, {'action': 'create', **data}, self.branch)
def commit(self, parallel: bool = True) -> List[ChangeSet]:
"""Commit all prepared bulk operations"""
return self.batch.commit(parallel=parallel)
class VersionControlQueue:
"""
Queue system for handling version control operations.
Allows for delayed processing and batching of changes.
"""
def __init__(self, batch_size: int = 100, auto_commit: bool = True):
self.batch_size = batch_size
self.auto_commit = auto_commit
self.current_batch = BatchOperation()
self._queued_count = 0
def queue_change(self, obj: Any, data: Dict[str, Any], branch: Optional[VersionBranch] = None) -> None:
"""Queue a change for processing"""
self.current_batch.add_change(obj, data, branch)
self._queued_count += 1
if self.auto_commit and self._queued_count >= self.batch_size:
self.process_queue()
def process_queue(self, parallel: bool = True) -> List[ChangeSet]:
"""Process all queued changes"""
if not self._queued_count:
return []
results = self.current_batch.commit(parallel=parallel)
self._queued_count = 0
return results
def batch_version_control(func):
"""
Decorator for batching version control operations within a function.
"""
def wrapper(*args, **kwargs):
batch = BatchOperation()
try:
with transaction.atomic():
result = func(*args, batch=batch, **kwargs)
if batch.changes:
batch.commit()
return result
except Exception as e:
logger.error(f"Batch operation failed: {e}")
raise
return wrapper

View File

@@ -1,223 +0,0 @@
from django.core.cache import cache
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from typing import Optional, List, Dict, Any
import hashlib
import json
import logging
logger = logging.getLogger('version_control')
class VersionHistoryCache:
"""
Caching system for version control history data.
Implements a multi-level caching strategy with memory and persistent storage.
"""
# Cache key prefixes
BRANCH_PREFIX = 'vc_branch_'
CHANGE_PREFIX = 'vc_change_'
HISTORY_PREFIX = 'vc_history_'
# Cache durations (in seconds)
BRANCH_CACHE_DURATION = 3600 # 1 hour
CHANGE_CACHE_DURATION = 1800 # 30 minutes
HISTORY_CACHE_DURATION = 3600 * 24 # 24 hours
@classmethod
def get_branch_key(cls, branch_id: int) -> str:
"""Generate cache key for branch data"""
return f"{cls.BRANCH_PREFIX}{branch_id}"
@classmethod
def get_change_key(cls, change_id: int) -> str:
"""Generate cache key for change data"""
return f"{cls.CHANGE_PREFIX}{change_id}"
@classmethod
def get_history_key(cls, content_type_id: int, object_id: int) -> str:
"""Generate cache key for object history"""
return f"{cls.HISTORY_PREFIX}{content_type_id}_{object_id}"
@classmethod
def generate_cache_version(cls, data: Dict[str, Any]) -> str:
"""Generate version hash for cache invalidation"""
data_str = json.dumps(data, sort_keys=True)
return hashlib.md5(data_str.encode()).hexdigest()
@classmethod
def cache_branch(cls, branch_data: Dict[str, Any]) -> None:
"""Cache branch data with versioning"""
key = cls.get_branch_key(branch_data['id'])
version = cls.generate_cache_version(branch_data)
cache_data = {
'data': branch_data,
'version': version,
'timestamp': settings.VERSION_CONTROL_TIMESTAMP
}
try:
cache.set(key, cache_data, cls.BRANCH_CACHE_DURATION)
logger.debug(f"Cached branch data: {key}")
except Exception as e:
logger.error(f"Error caching branch data: {e}")
@classmethod
def get_cached_branch(cls, branch_id: int) -> Optional[Dict[str, Any]]:
"""Retrieve cached branch data if valid"""
key = cls.get_branch_key(branch_id)
cache_data = cache.get(key)
if cache_data:
# Validate cache version and timestamp
if (
cache_data.get('timestamp') == settings.VERSION_CONTROL_TIMESTAMP and
cls.generate_cache_version(cache_data['data']) == cache_data['version']
):
logger.debug(f"Cache hit for branch: {key}")
return cache_data['data']
# Invalid cache, delete it
cache.delete(key)
logger.debug(f"Invalidated branch cache: {key}")
return None
@classmethod
def cache_change(cls, change_data: Dict[str, Any]) -> None:
"""Cache change data"""
key = cls.get_change_key(change_data['id'])
version = cls.generate_cache_version(change_data)
cache_data = {
'data': change_data,
'version': version,
'timestamp': settings.VERSION_CONTROL_TIMESTAMP
}
try:
cache.set(key, cache_data, cls.CHANGE_CACHE_DURATION)
logger.debug(f"Cached change data: {key}")
except Exception as e:
logger.error(f"Error caching change data: {e}")
@classmethod
def get_cached_change(cls, change_id: int) -> Optional[Dict[str, Any]]:
"""Retrieve cached change data if valid"""
key = cls.get_change_key(change_id)
cache_data = cache.get(key)
if cache_data:
if (
cache_data.get('timestamp') == settings.VERSION_CONTROL_TIMESTAMP and
cls.generate_cache_version(cache_data['data']) == cache_data['version']
):
logger.debug(f"Cache hit for change: {key}")
return cache_data['data']
cache.delete(key)
logger.debug(f"Invalidated change cache: {key}")
return None
@classmethod
def cache_history(cls, content_type_id: int, object_id: int, history_data: List[Dict[str, Any]]) -> None:
"""Cache version history for an object"""
key = cls.get_history_key(content_type_id, object_id)
version = cls.generate_cache_version({'history': history_data})
cache_data = {
'data': history_data,
'version': version,
'timestamp': settings.VERSION_CONTROL_TIMESTAMP
}
try:
cache.set(key, cache_data, cls.HISTORY_CACHE_DURATION)
logger.debug(f"Cached history data: {key}")
except Exception as e:
logger.error(f"Error caching history data: {e}")
@classmethod
def get_cached_history(cls, content_type_id: int, object_id: int) -> Optional[List[Dict[str, Any]]]:
"""Retrieve cached history data if valid"""
key = cls.get_history_key(content_type_id, object_id)
cache_data = cache.get(key)
if cache_data:
if (
cache_data.get('timestamp') == settings.VERSION_CONTROL_TIMESTAMP and
cls.generate_cache_version({'history': cache_data['data']}) == cache_data['version']
):
logger.debug(f"Cache hit for history: {key}")
return cache_data['data']
cache.delete(key)
logger.debug(f"Invalidated history cache: {key}")
return None
@classmethod
def invalidate_branch(cls, branch_id: int) -> None:
"""Invalidate branch cache"""
key = cls.get_branch_key(branch_id)
cache.delete(key)
logger.debug(f"Manually invalidated branch cache: {key}")
@classmethod
def invalidate_change(cls, change_id: int) -> None:
"""Invalidate change cache"""
key = cls.get_change_key(change_id)
cache.delete(key)
logger.debug(f"Manually invalidated change cache: {key}")
@classmethod
def invalidate_history(cls, content_type_id: int, object_id: int) -> None:
"""Invalidate history cache"""
key = cls.get_history_key(content_type_id, object_id)
cache.delete(key)
logger.debug(f"Manually invalidated history cache: {key}")
@classmethod
def invalidate_all(cls) -> None:
"""Invalidate all version control caches"""
try:
# Get all keys with our prefixes
keys = []
for prefix in [cls.BRANCH_PREFIX, cls.CHANGE_PREFIX, cls.HISTORY_PREFIX]:
keys.extend(cache.keys(f"{prefix}*"))
# Delete all matching keys
cache.delete_many(keys)
logger.info(f"Invalidated {len(keys)} version control cache entries")
except Exception as e:
logger.error(f"Error invalidating all caches: {e}")
class CacheableVersionMixin:
"""Mixin to add caching capabilities to version control models"""
def cache_data(self) -> None:
"""Cache the object's data"""
if hasattr(self, 'to_dict'):
data = self.to_dict()
if hasattr(self, 'branch_id'):
VersionHistoryCache.cache_branch(data)
elif hasattr(self, 'change_id'):
VersionHistoryCache.cache_change(data)
def invalidate_cache(self) -> None:
"""Invalidate the object's cache"""
if hasattr(self, 'branch_id'):
VersionHistoryCache.invalidate_branch(self.branch_id)
elif hasattr(self, 'change_id'):
VersionHistoryCache.invalidate_change(self.change_id)
def invalidate_related_caches(self) -> None:
"""Invalidate related object caches"""
if hasattr(self, 'content_type_id') and hasattr(self, 'object_id'):
VersionHistoryCache.invalidate_history(
self.content_type_id,
self.object_id
)

View File

@@ -1,248 +0,0 @@
from django.db import transaction
from django.utils import timezone
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from typing import List, Dict, Any, Optional
from datetime import timedelta
import logging
import json
import os
from .models import VersionBranch, ChangeSet
from .caching import VersionHistoryCache
logger = logging.getLogger('version_control')
class VersionCleanup:
"""
Manages cleanup of old version control data through archival and deletion.
"""
def __init__(self):
self.archive_path = getattr(
settings,
'VERSION_CONTROL_ARCHIVE_PATH',
'version_archives'
)
self.retention_days = getattr(
settings,
'VERSION_CONTROL_RETENTION_DAYS',
90
)
self.merged_retention_days = getattr(
settings,
'VERSION_CONTROL_MERGED_RETENTION_DAYS',
30
)
self.ensure_archive_directory()
def ensure_archive_directory(self) -> None:
"""Ensure archive directory exists"""
if not os.path.exists(self.archive_path):
os.makedirs(self.archive_path)
def get_archive_filename(self, date: timezone.datetime) -> str:
"""Generate archive filename for a given date"""
return os.path.join(
self.archive_path,
f'version_archive_{date.strftime("%Y%m%d_%H%M%S")}.json'
)
@transaction.atomic
def archive_old_changes(self, batch_size: int = 1000) -> int:
"""Archive and clean up old changes"""
cutoff_date = timezone.now() - timedelta(days=self.retention_days)
# Get changes to archive
old_changes = ChangeSet.objects.filter(
created_at__lt=cutoff_date,
archived=False
)[:batch_size]
if not old_changes:
return 0
# Prepare archive data
archive_data = {
'timestamp': timezone.now().isoformat(),
'changes': [
{
'id': change.id,
'branch': change.branch_id,
'content_type': change.content_type_id,
'object_id': change.object_id,
'data': change.data,
'status': change.status,
'created_at': change.created_at.isoformat(),
'applied_at': change.applied_at.isoformat() if change.applied_at else None
}
for change in old_changes
]
}
# Write to archive file
archive_file = self.get_archive_filename(timezone.now())
with open(archive_file, 'w') as f:
json.dump(archive_data, f, indent=2)
# Mark changes as archived
change_ids = [change.id for change in old_changes]
ChangeSet.objects.filter(id__in=change_ids).update(archived=True)
logger.info(f"Archived {len(change_ids)} changes to {archive_file}")
return len(change_ids)
@transaction.atomic
def cleanup_merged_branches(self) -> int:
"""Clean up old merged branches"""
cutoff_date = timezone.now() - timedelta(days=self.merged_retention_days)
# Find merged branches to clean up
merged_branches = VersionBranch.objects.filter(
is_merged=True,
merged_at__lt=cutoff_date,
is_protected=False
)
count = 0
for branch in merged_branches:
try:
# Archive branch changes
self.archive_branch_changes(branch)
# Delete branch
branch.delete()
count += 1
logger.info(f"Cleaned up merged branch: {branch.name}")
except Exception as e:
logger.error(f"Error cleaning up branch {branch.name}: {e}")
return count
def archive_branch_changes(self, branch: VersionBranch) -> None:
"""Archive all changes for a specific branch"""
changes = ChangeSet.objects.filter(
branch=branch,
archived=False
)
if not changes:
return
archive_data = {
'timestamp': timezone.now().isoformat(),
'branch': {
'id': branch.id,
'name': branch.name,
'metadata': branch.metadata,
'created_at': branch.created_at.isoformat(),
'merged_at': branch.merged_at.isoformat() if branch.merged_at else None
},
'changes': [
{
'id': change.id,
'content_type': change.content_type_id,
'object_id': change.object_id,
'data': change.data,
'status': change.status,
'created_at': change.created_at.isoformat(),
'applied_at': change.applied_at.isoformat() if change.applied_at else None
}
for change in changes
]
}
# Write to archive file
archive_file = self.get_archive_filename(timezone.now())
with open(archive_file, 'w') as f:
json.dump(archive_data, f, indent=2)
# Mark changes as archived
changes.update(archived=True)
@transaction.atomic
def cleanup_inactive_branches(self, days: int = 60) -> int:
"""Clean up inactive branches"""
cutoff_date = timezone.now() - timedelta(days=days)
# Find inactive branches
inactive_branches = VersionBranch.objects.filter(
is_active=True,
is_protected=False,
updated_at__lt=cutoff_date
)
count = 0
for branch in inactive_branches:
try:
# Archive branch changes
self.archive_branch_changes(branch)
# Deactivate branch
branch.is_active = False
branch.save()
count += 1
logger.info(f"Deactivated inactive branch: {branch.name}")
except Exception as e:
logger.error(f"Error deactivating branch {branch.name}: {e}")
return count
def cleanup_orphaned_changes(self) -> int:
"""Clean up changes without valid content objects"""
count = 0
for change in ChangeSet.objects.filter(archived=False):
try:
# Try to get the related object
obj = change.content_type.get_object_for_this_type(
pk=change.object_id)
if obj is None:
self.archive_change(change)
count += 1
except Exception:
# If object doesn't exist, archive the change
self.archive_change(change)
count += 1
logger.info(f"Cleaned up {count} orphaned changes")
return count
def archive_change(self, change: ChangeSet) -> None:
"""Archive a single change"""
archive_data = {
'timestamp': timezone.now().isoformat(),
'changes': [{
'id': change.id,
'branch': change.branch_id,
'content_type': change.content_type_id,
'object_id': change.object_id,
'data': change.data,
'status': change.status,
'created_at': change.created_at.isoformat(),
'applied_at': change.applied_at.isoformat() if change.applied_at else None
}]
}
# Write to archive file
archive_file = self.get_archive_filename(timezone.now())
with open(archive_file, 'w') as f:
json.dump(archive_data, f, indent=2)
# Mark change as archived
change.archived = True
change.save()
def run_maintenance(self) -> Dict[str, int]:
"""Run all cleanup operations"""
results = {
'archived_changes': self.archive_old_changes(),
'cleaned_branches': self.cleanup_merged_branches(),
'deactivated_branches': self.cleanup_inactive_branches(),
'cleaned_orphans': self.cleanup_orphaned_changes()
}
logger.info("Version control maintenance completed", extra=results)
return results

View File

@@ -1,237 +0,0 @@
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

@@ -1,43 +0,0 @@
from typing import Dict, Any
from django.http import HttpRequest
from .signals import get_current_branch
from .models import VersionBranch, ChangeSet
def version_control(request: HttpRequest) -> Dict[str, Any]:
"""
Add version control information to the template context
"""
current_branch = get_current_branch()
context = {
'vcs_enabled': True,
'current_branch': current_branch,
'recent_changes': []
}
if current_branch:
# Get recent changes for the current branch
recent_changes = ChangeSet.objects.filter(
branch=current_branch,
status='applied'
).order_by('-created_at')[:5]
context.update({
'recent_changes': recent_changes,
'branch_name': current_branch.name,
'branch_metadata': current_branch.metadata
})
# Get available branches for switching
context['available_branches'] = VersionBranch.objects.filter(
is_active=True
).order_by('-created_at')
# Check if current page is versioned
if hasattr(request, 'resolver_match') and request.resolver_match:
view_func = request.resolver_match.func
if hasattr(view_func, 'view_class'):
view_class = view_func.view_class
context['page_is_versioned'] = hasattr(view_class, 'model') and \
hasattr(view_class.model, 'history')
return {'version_control': context}

View File

@@ -1,61 +0,0 @@
from django.db import models
from simple_history.models import HistoricalRecords
from django.contrib.contenttypes.fields import GenericRelation
from django.utils.timezone import now
class CustomHistoricalRecords(HistoricalRecords):
"""Custom historical records that properly handle generic relations."""
def copy_fields(self, model):
"""
Copy fields from the model to the historical record model,
excluding GenericRelation fields.
"""
fields = {}
for field in model._meta.concrete_fields:
if not isinstance(field, GenericRelation) and field.name not in [
'comments', 'comment_threads', 'photos', 'reviews'
]:
fields[field.name] = field.clone()
return fields
def create_history_model(self, model, inherited):
"""
Override to ensure we don't create duplicate auto fields.
"""
attrs = {
'__module__': model.__module__,
'_history_excluded_fields': ['comments', 'comment_threads', 'photos', 'reviews'],
}
app_module = '%s.models' % model._meta.app_label
if inherited:
# inherited use models.AutoField instead of models.IntegerField
attrs.update({
'id': models.AutoField(primary_key=True),
'history_id': models.AutoField(primary_key=True),
'history_date': models.DateTimeField(default=now),
'history_change_reason': models.CharField(max_length=100, null=True),
'history_type': models.CharField(max_length=1, choices=(
('+', 'Created'),
('~', 'Changed'),
('-', 'Deleted'),
)),
'history_user': models.ForeignKey(
'accounts.User',
null=True,
on_delete=models.SET_NULL,
related_name='+'
),
})
# Convert field to point to historical model
fields = self.copy_fields(model)
attrs.update(fields)
return type(
str('Historical%s' % model._meta.object_name),
(models.Model,),
attrs
)

View File

@@ -1,49 +0,0 @@
from django.db import models
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
from typing import List, Type
def get_trackable_fields(model_class: Type[models.Model]) -> List[models.Field]:
"""Get fields that should be tracked in history."""
if getattr(model_class, '_is_historical_model', False):
# For historical models, only return core history fields
return [
models.BigAutoField(name='id', primary_key=True),
models.DateTimeField(name='history_date'),
models.CharField(name='history_change_reason', max_length=100, null=True),
models.CharField(name='history_type', max_length=1),
models.ForeignKey(
to=settings.AUTH_USER_MODEL,
name='history_user',
null=True,
on_delete=models.SET_NULL
)
]
trackable_fields = []
excluded_fields = {
'comment_threads', 'comments', 'photos', 'reviews',
'thread', 'content_type', 'object_id', 'content_object'
}
for field in model_class._meta.get_fields():
# Skip fields we don't want to track
if any([
isinstance(field, (GenericRelation, GenericForeignKey)),
field.name in excluded_fields,
field.is_relation and hasattr(field.remote_field.model, '_meta') and
'commentthread' in field.remote_field.model._meta.model_name.lower()
]):
continue
trackable_fields.append(field)
return trackable_fields
class HistoricalFieldsMixin:
"""Mixin that controls which fields are copied to historical models."""
@classmethod
def get_fields_to_track(cls) -> List[models.Field]:
"""Get fields that should be tracked in history."""
return get_trackable_fields(cls)

View File

@@ -1,123 +0,0 @@
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, HistoricalCommentThread, 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 = HistoricalCommentThread.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 = HistoricalCommentThread.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

@@ -1,519 +0,0 @@
from typing import Optional, List, Dict, Any, Tuple, Type, TypeVar, cast
from django.db import transaction
from django.core.exceptions import ValidationError
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)
User = cast(Type[UserModel], get_user_model())
class BranchManager:
"""Manages version control branch operations"""
@transaction.atomic
def create_branch(
self,
name: str,
parent: Optional[VersionBranch] = None,
user: Optional[UserModel] = None
) -> VersionBranch:
"""Create a new version branch"""
branch = VersionBranch.objects.create(
name=name,
parent=parent,
created_by=user,
metadata={
'created_from': parent.name if parent else 'root',
'created_at': timezone.now().isoformat()
}
)
branch.full_clean()
return branch
@transaction.atomic
def merge_branches(
self,
source: VersionBranch,
target: VersionBranch,
user: Optional[UserModel] = None
) -> Tuple[bool, List[Dict[str, Any]]]:
"""
Merge source branch into target branch
Returns: (success, conflicts)
"""
if not source.is_active or not target.is_active:
raise ValidationError("Cannot merge inactive branches")
merger = MergeStrategy()
success, conflicts = merger.auto_merge(source, target)
if success:
# Record successful merge
ChangeSet.objects.create(
branch=target,
created_by=user,
description=f"Merged branch '{source.name}' into '{target.name}'",
metadata={
'merge_source': source.name,
'merge_target': target.name,
'merged_at': timezone.now().isoformat()
},
status='applied'
)
return success, conflicts
def list_branches(self, include_inactive: bool = False) -> List[VersionBranch]:
"""Get all branches with their relationships"""
queryset = VersionBranch.objects.select_related('parent')
if not include_inactive:
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"""
@transaction.atomic
def record_change(
self,
instance: Any,
change_type: str,
branch: VersionBranch,
user: Optional[UserModel] = None,
metadata: Optional[Dict] = None
) -> ChangeSet:
"""Record a change in the system"""
if not hasattr(instance, 'history'):
raise ValueError("Instance must be a model with history tracking enabled")
# Create historical record by saving the instance
instance.save()
historical_record = instance.history.first()
if not historical_record:
raise ValueError("Failed to create historical record")
# Create changeset
content_type = ContentType.objects.get_for_model(historical_record)
changeset = ChangeSet.objects.create(
branch=branch,
created_by=user,
description=f"{change_type} operation on {instance._meta.model_name}",
metadata=metadata or {},
status='pending',
content_type=content_type,
object_id=historical_record.pk
)
return changeset
def get_changes(self, branch: VersionBranch) -> List[ChangeSet]:
"""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"""
def auto_merge(
self,
source: VersionBranch,
target: VersionBranch
) -> Tuple[bool, List[Dict[str, Any]]]:
"""
Attempt automatic merge between branches
Returns: (success, conflicts)
"""
conflicts = []
# Get all changes since branch creation
source_changes = ChangeSet.objects.filter(
branch=source,
status='applied'
).order_by('created_at')
target_changes = ChangeSet.objects.filter(
branch=target,
status='applied'
).order_by('created_at')
# Detect conflicts
for source_change in source_changes:
for target_change in target_changes:
if self._detect_conflict(source_change, target_change):
conflicts.append({
'source_change': source_change.pk,
'target_change': target_change.pk,
'type': 'content_conflict',
'description': 'Conflicting changes detected'
})
if conflicts:
return False, conflicts
# No conflicts, apply source changes to target
for change in source_changes:
self._apply_change_to_branch(change, target)
return True, []
def _detect_conflict(self, change1: ChangeSet, change2: ChangeSet) -> bool:
"""Check if two changes conflict with each other"""
# Get historical instances
instance1 = change1.historical_instance
instance2 = change2.historical_instance
if not (instance1 and instance2):
return False
# Same model and instance ID indicates potential conflict
return (
instance1._meta.model == instance2._meta.model and
instance1.id == instance2.id
)
@transaction.atomic
def _apply_change_to_branch(
self,
change: ChangeSet,
target_branch: VersionBranch
) -> None:
"""Apply a change from one branch to another"""
# Create new changeset in target branch
new_changeset = ChangeSet.objects.create(
branch=target_branch,
description=f"Applied change from '{change.branch.name}'",
metadata={
'source_change': change.pk,
'source_branch': change.branch.name
},
status='pending',
content_type=change.content_type,
object_id=change.object_id
)
new_changeset.status = 'applied'
new_changeset.save()

View File

@@ -1,220 +0,0 @@
# Generated by Django 5.1.6 on 2025-02-06 22:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("contenttypes", "0002_remove_content_type_name"),
("history_tracking", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="VersionBranch",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255, unique=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("is_active", models.BooleanField(default=True)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
(
"parent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="history_tracking.versionbranch",
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="ChangeSet",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("description", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("dependencies", models.JSONField(blank=True, default=dict)),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("applied", "Applied"),
("failed", "Failed"),
("reverted", "Reverted"),
],
default="pending",
max_length=20,
),
),
("object_id", models.PositiveIntegerField()),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
(
"branch",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="changesets",
to="history_tracking.versionbranch",
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.CreateModel(
name="VersionTag",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255, unique=True)),
("object_id", models.PositiveIntegerField()),
("created_at", models.DateTimeField(auto_now_add=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"branch",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="tags",
to="history_tracking.versionbranch",
),
),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="contenttypes.contenttype",
),
),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="versionbranch",
index=models.Index(fields=["name"], name="history_tra_name_cf8692_idx"),
),
migrations.AddIndex(
model_name="versionbranch",
index=models.Index(
fields=["parent"], name="history_tra_parent__c645fa_idx"
),
),
migrations.AddIndex(
model_name="versionbranch",
index=models.Index(
fields=["created_at"], name="history_tra_created_6f9fc9_idx"
),
),
migrations.AddIndex(
model_name="changeset",
index=models.Index(
fields=["branch"], name="history_tra_branch__0c1728_idx"
),
),
migrations.AddIndex(
model_name="changeset",
index=models.Index(
fields=["created_at"], name="history_tra_created_c0fe58_idx"
),
),
migrations.AddIndex(
model_name="changeset",
index=models.Index(fields=["status"], name="history_tra_status_93e04d_idx"),
),
migrations.AddIndex(
model_name="changeset",
index=models.Index(
fields=["content_type", "object_id"],
name="history_tra_content_9f97ff_idx",
),
),
migrations.AddIndex(
model_name="versiontag",
index=models.Index(fields=["name"], name="history_tra_name_38da60_idx"),
),
migrations.AddIndex(
model_name="versiontag",
index=models.Index(
fields=["branch"], name="history_tra_branch__0a9a55_idx"
),
),
migrations.AddIndex(
model_name="versiontag",
index=models.Index(
fields=["created_at"], name="history_tra_created_7a1501_idx"
),
),
migrations.AddIndex(
model_name="versiontag",
index=models.Index(
fields=["content_type", "object_id"],
name="history_tra_content_0892f3_idx",
),
),
]

View File

@@ -1,11 +1,9 @@
# 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)
@@ -35,7 +33,6 @@ 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 {}
@@ -57,69 +54,11 @@ class HistoricalChangeMixin(models.Model):
old_value = getattr(prev_record, field)
new_value = getattr(self, field)
if old_value != 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)
}
}
changes[field] = {"old": str(old_value), "new": str(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,43 +1,20 @@
# history_tracking/models.py
from django.db import models
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.db.models.fields.related import RelatedField
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericForeignKey
from simple_history.models import HistoricalRecords
from .mixins import HistoricalChangeMixin
from .historical_fields import HistoricalFieldsMixin
from typing import Any, Type, TypeVar, cast, Optional, List
from typing import Any, Type, TypeVar, cast
from django.db.models import QuerySet
from django.core.exceptions import ValidationError
from django.utils import timezone
T = TypeVar('T', bound=models.Model)
User = get_user_model()
class HistoricalModel(models.Model, HistoricalFieldsMixin):
class HistoricalModel(models.Model):
"""Abstract base class for models with history tracking"""
id = models.BigAutoField(primary_key=True)
@classmethod
def __init_subclass__(cls, **kwargs):
"""Initialize subclass with proper configuration."""
super().__init_subclass__(**kwargs)
# Mark historical models
if cls.__name__.startswith('Historical'):
cls._is_historical_model = True
# Remove any inherited generic relations
for field in list(cls._meta.private_fields):
if isinstance(field, GenericRelation):
cls._meta.private_fields.remove(field)
else:
cls._is_historical_model = False
history = HistoricalRecords(
history: HistoricalRecords = HistoricalRecords(
inherit=True,
bases=[HistoricalChangeMixin],
excluded_fields=['comments', 'comment_threads', 'photos', 'reviews'],
use_base_model_db=True # Use base model's db
bases=(HistoricalChangeMixin,)
)
class Meta:
@@ -70,233 +47,3 @@ class HistoricalSlug(models.Model):
def __str__(self) -> str:
return f"{self.content_type} - {self.object_id} - {self.slug}"
class VersionBranch(models.Model):
"""Represents a version control branch for tracking parallel development"""
name = models.CharField(max_length=255, unique=True)
parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE, related_name='children')
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)
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']
indexes = [
models.Index(fields=['name']),
models.Index(fields=['parent']),
models.Index(fields=['created_at']),
]
def __str__(self) -> str:
return f"{self.name} ({'active' if self.is_active else 'inactive'})"
def clean(self) -> None:
# Prevent circular references
if self.parent and self.pk:
branch = self.parent
while branch:
if branch.pk == self.pk:
raise ValidationError("Circular branch reference detected")
branch = branch.parent
class VersionTag(models.Model):
"""Tags specific versions for reference (releases, milestones, etc)"""
name = models.CharField(max_length=255, unique=True)
branch = models.ForeignKey(VersionBranch, on_delete=models.CASCADE, related_name='tags')
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
historical_instance = 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)
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']
indexes = [
models.Index(fields=['name']),
models.Index(fields=['branch']),
models.Index(fields=['created_at']),
models.Index(fields=['content_type', 'object_id']),
]
def __str__(self) -> str:
return f"{self.name} ({self.branch.name})"
class HistoricalCommentThread(models.Model):
"""Represents a thread of comments specific to historical records and version control"""
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(HistoricalCommentThread, 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')
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
description = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
dependencies = models.JSONField(default=dict, blank=True)
status = models.CharField(
max_length=20,
choices=[
('draft', 'Draft'),
('pending_approval', 'Pending Approval'),
('approved', 'Approved'),
('rejected', 'Rejected'),
('applied', 'Applied'),
('failed', 'Failed'),
('reverted', 'Reverted')
],
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
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField()
historical_instance = GenericForeignKey('content_type', 'object_id')
class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['branch']),
models.Index(fields=['created_at']),
models.Index(fields=['status']),
models.Index(fields=['content_type', 'object_id']),
]
def __str__(self) -> str:
return f"ChangeSet {self.pk} ({self.branch.name} - {self.status})"
def apply(self) -> None:
"""Apply the changeset to the target branch"""
if self.status != 'pending':
raise ValidationError(f"Cannot apply changeset with status: {self.status}")
try:
# Apply changes through the historical instance
if self.historical_instance:
instance = self.historical_instance.instance
if instance:
instance.save()
self.status = 'applied'
except Exception as e:
self.status = 'failed'
self.metadata['error'] = str(e)
self.save()
def revert(self) -> None:
"""Revert the changes in this changeset"""
if self.status != 'applied':
raise ValidationError(f"Cannot revert changeset with status: {self.status}")
try:
# Revert changes through the historical instance
if self.historical_instance:
instance = self.historical_instance.instance
if instance:
instance.save()
self.status = 'reverted'
except Exception as e:
self.metadata['revert_error'] = str(e)
self.save()

View File

@@ -1,202 +0,0 @@
import logging
import time
from functools import wraps
from django.conf import settings
from django.db import connection
# Configure logger
logger = logging.getLogger('version_control')
def track_operation_timing(operation_name):
"""Decorator to track timing of version control operations"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
start_time = time.time()
try:
result = func(*args, **kwargs)
duration = time.time() - start_time
# Log timing metrics
logger.info(
'Version Control Operation Timing',
extra={
'operation': operation_name,
'duration': duration,
'success': True
}
)
return result
except Exception as e:
duration = time.time() - start_time
logger.error(
'Version Control Operation Failed',
extra={
'operation': operation_name,
'duration': duration,
'error': str(e),
'success': False
}
)
raise
return wrapper
return decorator
def track_merge_result(source_branch, target_branch, success, conflict_count=0):
"""Track the results of merge operations"""
logger.info(
'Branch Merge Operation',
extra={
'source_branch': source_branch.name,
'target_branch': target_branch.name,
'success': success,
'conflict_count': conflict_count
}
)
def track_branch_metrics(branch):
"""Track metrics for a specific branch"""
from history_tracking.models import ChangeSet
changes = ChangeSet.objects.filter(branch=branch)
applied_changes = changes.filter(status='applied')
pending_changes = changes.filter(status='pending')
logger.info(
'Branch Metrics',
extra={
'branch_name': branch.name,
'total_changes': changes.count(),
'applied_changes': applied_changes.count(),
'pending_changes': pending_changes.count(),
'is_active': branch.is_active
}
)
def track_database_metrics():
"""Track database metrics for version control operations"""
with connection.execute_wrapper(StatementLogger()):
yield
class StatementLogger:
"""Log database statements for monitoring"""
def __call__(self, execute, sql, params, many, context):
start = time.time()
try:
result = execute(sql, params, many, context)
duration = time.time() - start
# Log only version control related queries
if 'version' in sql.lower() or 'changeset' in sql.lower():
logger.info(
'Version Control DB Operation',
extra={
'sql': sql,
'duration': duration,
'success': True
}
)
return result
except Exception as e:
duration = time.time() - start
logger.error(
'Version Control DB Operation Failed',
extra={
'sql': sql,
'duration': duration,
'error': str(e),
'success': False
}
)
raise
class VersionControlMetrics:
"""Collect and report version control system metrics"""
@staticmethod
def collect_system_metrics():
"""Collect overall system metrics"""
from history_tracking.models import VersionBranch, ChangeSet
total_branches = VersionBranch.objects.count()
active_branches = VersionBranch.objects.filter(is_active=True).count()
total_changes = ChangeSet.objects.count()
pending_changes = ChangeSet.objects.filter(status='pending').count()
conflicted_merges = ChangeSet.objects.filter(
status='conflict'
).count()
logger.info(
'Version Control System Metrics',
extra={
'total_branches': total_branches,
'active_branches': active_branches,
'total_changes': total_changes,
'pending_changes': pending_changes,
'conflicted_merges': conflicted_merges
}
)
@staticmethod
def collect_performance_metrics():
"""Collect performance-related metrics"""
from django.db import connection
from django.core.cache import cache
# Database metrics
with connection.execute_wrapper(StatementLogger()):
db_metrics = {
'total_queries': len(connection.queries),
'total_time': sum(
float(q['time']) for q in connection.queries
)
}
# Cache metrics
cache_metrics = {
'hits': cache.get('version_control_cache_hits', 0),
'misses': cache.get('version_control_cache_misses', 0)
}
logger.info(
'Version Control Performance Metrics',
extra={
'database': db_metrics,
'cache': cache_metrics
}
)
@staticmethod
def track_user_operations(user, operation, success):
"""Track user operations on version control"""
logger.info(
'Version Control User Operation',
extra={
'user_id': user.id,
'username': user.username,
'operation': operation,
'success': success
}
)
def setup_monitoring():
"""Configure monitoring for version control system"""
if not settings.DEBUG:
# Configure logging handlers
handler = logging.handlers.RotatingFileHandler(
'logs/version_control.log',
maxBytes=10485760, # 10MB
backupCount=5
)
handler.setFormatter(logging.Formatter(
'%(asctime)s [%(levelname)s] %(message)s'
))
logger.addHandler(handler)
# Set up error reporting
import sentry_sdk # type: ignore
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
traces_sample_rate=0.1,
profiles_sample_rate=0.1,
)

View File

@@ -1,229 +0,0 @@
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

@@ -1,138 +0,0 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from simple_history.signals import post_create_historical_record
from django.contrib.auth import get_user_model
from django.db import transaction
from .models import VersionBranch, ChangeSet, HistoricalModel
from .managers import ChangeTracker
import threading
User = get_user_model()
# Thread-local storage for tracking active changesets
_changeset_context = threading.local()
def get_current_branch():
"""Get the currently active branch for the thread"""
return getattr(_changeset_context, 'current_branch', None)
def set_current_branch(branch):
"""Set the active branch for the current thread"""
_changeset_context.current_branch = branch
def clear_current_branch():
"""Clear the active branch for the current thread"""
if hasattr(_changeset_context, 'current_branch'):
del _changeset_context.current_branch
class ChangesetContextManager:
"""Context manager for tracking changes in a specific branch"""
def __init__(self, branch, user=None):
self.branch = branch
self.user = user
self.previous_branch = None
def __enter__(self):
self.previous_branch = get_current_branch()
set_current_branch(self.branch)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
set_current_branch(self.previous_branch)
@receiver(post_create_historical_record)
def handle_history_record(sender, instance, history_instance, **kwargs):
"""Handle creation of historical records by adding them to changesets"""
# Only handle records from HistoricalModel subclasses
if not isinstance(instance, HistoricalModel):
return
branch = get_current_branch()
if not branch:
# If no branch is set, use the default branch
branch, _ = VersionBranch.objects.get_or_create(
name='main',
defaults={
'metadata': {
'type': 'default_branch',
'created_automatically': True
}
}
)
# Create or get active changeset for the current branch
changeset = getattr(_changeset_context, 'active_changeset', None)
if not changeset:
changeset = ChangeSet.objects.create(
branch=branch,
created_by=history_instance.history_user,
description=f"Automatic change tracking: {history_instance.history_type}",
metadata={
'auto_tracked': True,
'model': instance._meta.model_name,
'history_type': history_instance.history_type
},
status='applied'
)
_changeset_context.active_changeset = changeset
# Add the historical record to the changeset
changeset.historical_records.add(history_instance)
@receiver(post_save, sender=ChangeSet)
def handle_changeset_save(sender, instance, created, **kwargs):
"""Handle changeset creation by updating related objects"""
if created and instance.status == 'applied':
# Clear the active changeset if this is the one we were using
active_changeset = getattr(_changeset_context, 'active_changeset', None)
if active_changeset and active_changeset.id == instance.id:
delattr(_changeset_context, 'active_changeset')
# Update branch metadata
branch = instance.branch
if not branch.metadata.get('first_change'):
branch.metadata['first_change'] = instance.created_at.isoformat()
branch.metadata['last_change'] = instance.created_at.isoformat()
branch.metadata['change_count'] = branch.changesets.count()
branch.save()
def start_changeset(branch, user=None, description=None):
"""Start a new changeset in the given branch"""
changeset = ChangeSet.objects.create(
branch=branch,
created_by=user,
description=description or "Manual changeset",
status='pending'
)
_changeset_context.active_changeset = changeset
return changeset
def commit_changeset(success=True):
"""Commit the current changeset"""
changeset = getattr(_changeset_context, 'active_changeset', None)
if changeset:
changeset.status = 'applied' if success else 'failed'
changeset.save()
delattr(_changeset_context, 'active_changeset')
return changeset
class ChangesetManager:
"""Context manager for handling changesets"""
def __init__(self, branch, user=None, description=None):
self.branch = branch
self.user = user
self.description = description
self.changeset = None
def __enter__(self):
self.changeset = start_changeset(
self.branch,
self.user,
self.description
)
return self.changeset
def __exit__(self, exc_type, exc_val, exc_tb):
commit_changeset(success=exc_type is None)

View File

@@ -1,194 +0,0 @@
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

@@ -1,174 +0,0 @@
{% 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

@@ -1,58 +0,0 @@
<div class="bg-white rounded-lg shadow p-4 mb-4">
<h3 class="text-lg font-semibold mb-4">Create New Branch</h3>
<form hx-post="{% url 'history:branch-create' %}"
hx-target="#branch-list"
hx-swap="afterbegin"
class="space-y-4">
{% csrf_token %}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Branch Name
</label>
<input type="text" name="name" required
placeholder="feature/my-new-branch"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
pattern="[a-zA-Z0-9/_-]+"
title="Only letters, numbers, underscores, forward slashes, and hyphens allowed">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Parent Branch
</label>
<select name="parent"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">-- Create from root --</option>
{% for branch in branches %}
<option value="{{ branch.name }}">{{ branch.name }}</option>
{% endfor %}
</select>
<p class="text-sm text-gray-500 mt-1">
Leave empty to create from root
</p>
</div>
<div class="flex justify-end space-x-3">
<button type="button"
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick="document.getElementById('branch-form-container').innerHTML = ''">
Cancel
</button>
<button type="submit"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600">
Create Branch
</button>
</div>
</form>
</div>
<script>
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.successful && evt.detail.target.id === 'branch-list') {
document.getElementById('branch-form-container').innerHTML = '';
document.body.dispatchEvent(new CustomEvent('branch-updated'));
}
});
</script>

View File

@@ -1,43 +0,0 @@
<div class="branch-list space-y-2">
{% for branch in branches %}
<div class="branch-item p-2 bg-gray-50 rounded hover:bg-gray-100 cursor-pointer {% if branch.name == current_branch %}bg-blue-50 border-l-4 border-blue-500{% endif %}"
hx-get="{% url 'history:history-view' %}?branch={{ branch.name }}"
hx-target="#history-view"
hx-trigger="click"
onclick="selectBranch('{{ branch.name }}')">
<div class="flex justify-between items-center">
<span class="font-medium text-gray-700">{{ branch.name }}</span>
{% if branch.is_active %}
<span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">Active</span>
{% endif %}
</div>
{% if branch.parent %}
<div class="text-xs text-gray-500 mt-1">
from: {{ branch.parent.name }}
</div>
{% endif %}
<div class="flex space-x-2 mt-2">
<button class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600"
hx-get="{% url 'history:merge-view' %}?source={{ branch.name }}"
hx-target="#merge-panel"
hx-trigger="click">
Merge
</button>
<button class="text-xs bg-purple-500 text-white px-2 py-1 rounded hover:bg-purple-600"
hx-get="{% url 'history:tag-create' %}?branch={{ branch.name }}"
hx-target="#tag-form-container"
hx-trigger="click">
Tag
</button>
</div>
</div>
{% endfor %}
</div>
<script>
function selectBranch(name) {
document.body.dispatchEvent(new CustomEvent('branch-selected', {
detail: { branch: name }
}));
}
</script>

View File

@@ -1,88 +0,0 @@
<div class="history-view">
<h3 class="text-xl font-semibold mb-4">Change History</h3>
{% if changes %}
<div class="space-y-4">
{% for change in changes %}
<div class="change-item bg-gray-50 p-4 rounded-lg">
<div class="flex justify-between items-start">
<div>
<h4 class="font-medium text-gray-900">{{ change.description }}</h4>
<div class="text-sm text-gray-500 mt-1">
{{ change.created_at|date:"M d, Y H:i" }}
{% if change.created_by %}
by {{ change.created_by.username }}
{% endif %}
</div>
</div>
<span class="px-2 py-1 text-sm rounded
{% if change.status == 'applied' %}
bg-green-100 text-green-800
{% elif change.status == 'pending' %}
bg-yellow-100 text-yellow-800
{% elif change.status == 'failed' %}
bg-red-100 text-red-800
{% elif change.status == 'reverted' %}
bg-gray-100 text-gray-800
{% endif %}">
{{ change.status|title }}
</span>
</div>
{% if change.historical_records.exists %}
<div class="mt-3 space-y-2">
{% for record in change.historical_records.all %}
<div class="text-sm bg-white p-2 rounded border border-gray-200">
<div class="font-medium">
{{ record.instance_type|title }}
{% if record.history_type == '+' %}
created
{% elif record.history_type == '-' %}
deleted
{% else %}
modified
{% endif %}
</div>
{% if record.history_type == '~' and record.diff_to_prev %}
<div class="mt-1 text-gray-600">
Changes:
<ul class="list-disc list-inside">
{% for field, values in record.diff_to_prev.items %}
<li>{{ field }}: {{ values.old }} → {{ values.new }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% if change.metadata %}
<div class="mt-3 text-sm text-gray-600">
<details>
<summary class="cursor-pointer">Additional Details</summary>
<pre class="mt-2 bg-gray-100 p-2 rounded text-xs overflow-x-auto">{{ change.metadata|pprint }}</pre>
</details>
</div>
{% endif %}
{% if change.status == 'applied' %}
<div class="mt-3">
<button class="text-sm bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600"
hx-post="{% url 'history:revert-change' change.id %}"
hx-confirm="Are you sure you want to revert this change?"
hx-target="#history-view">
Revert Change
</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-gray-500 text-center py-8">
No changes recorded for this branch yet.
</div>
{% endif %}
</div>

View File

@@ -1,116 +0,0 @@
<div class="merge-conflicts bg-white rounded-lg shadow p-4">
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-lg font-medium text-yellow-800">
Merge Conflicts Detected
</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>Conflicts were found while merging '{{ source.name }}' into '{{ target.name }}'.</p>
</div>
</div>
</div>
</div>
<div class="space-y-4">
<h4 class="font-medium text-gray-900">Conflicts to Resolve:</h4>
<form hx-post="{% url 'history:resolve-conflicts' %}"
hx-target="#merge-panel">
{% csrf_token %}
<input type="hidden" name="source" value="{{ source.name }}">
<input type="hidden" name="target" value="{{ target.name }}">
{% for conflict in conflicts %}
<div class="bg-gray-50 p-4 rounded-lg mb-4">
<div class="flex justify-between items-start mb-2">
<h5 class="font-medium text-gray-900">
Conflict #{{ forloop.counter }}
</h5>
<span class="text-sm text-gray-500">
Type: {{ conflict.type }}
</span>
</div>
<div class="text-sm text-gray-600 mb-3">
{{ conflict.description }}
</div>
{% if conflict.type == 'content_conflict' %}
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Source Version ({{ source.name }})
</label>
<div class="bg-white p-2 rounded border border-gray-200">
<pre class="text-sm overflow-x-auto">{{ conflict.source_content }}</pre>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Target Version ({{ target.name }})
</label>
<div class="bg-white p-2 rounded border border-gray-200">
<pre class="text-sm overflow-x-auto">{{ conflict.target_content }}</pre>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Resolution
</label>
<select name="resolution_{{ conflict.source_change }}_{{ conflict.target_change }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="source">Use source version</option>
<option value="target">Use target version</option>
<option value="manual">Resolve manually</option>
</select>
</div>
<div class="manual-resolution hidden">
<label class="block text-sm font-medium text-gray-700 mb-1">
Manual Resolution
</label>
<textarea name="manual_{{ conflict.source_change }}_{{ conflict.target_change }}"
rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
placeholder="Enter manual resolution here..."></textarea>
</div>
</div>
{% endif %}
</div>
{% endfor %}
<div class="flex justify-end space-x-3 mt-4">
<button type="button"
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick="document.getElementById('merge-panel').innerHTML = ''">
Cancel
</button>
<button type="submit"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600">
Apply Resolutions
</button>
</div>
</form>
</div>
</div>
<script>
document.addEventListener('change', function(e) {
if (e.target.tagName === 'SELECT' && e.target.name.startsWith('resolution_')) {
const manualDiv = e.target.parentElement.nextElementSibling;
if (e.target.value === 'manual') {
manualDiv.classList.remove('hidden');
} else {
manualDiv.classList.add('hidden');
}
}
});
</script>

View File

@@ -1,49 +0,0 @@
<div class="merge-panel">
<h3 class="text-xl font-semibold mb-4">Merge Branches</h3>
<form hx-post="{% url 'history:merge-view' %}"
hx-target="#merge-panel"
class="space-y-4">
{% csrf_token %}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Source Branch
</label>
<input type="text" name="source" value="{{ source }}" readonly
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Target Branch
</label>
<select name="target" required
class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">Select target branch...</option>
{% for branch in branches %}
{% if branch.name != source %}
<option value="{{ branch.name }}"
{% if branch.name == target %}selected{% endif %}>
{{ branch.name }}
</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
<div class="flex justify-end space-x-3">
<button type="button"
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
onclick="document.getElementById('merge-panel').innerHTML = ''">
Cancel
</button>
<button type="submit"
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600">
Start Merge
</button>
</div>
</form>
</div>

View File

@@ -1,30 +0,0 @@
<div class="merge-result bg-white rounded-lg shadow p-4">
<div class="bg-green-50 border-l-4 border-green-400 p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-lg font-medium text-green-800">
Merge Successful
</h3>
<div class="mt-2 text-sm text-green-700">
<p>Successfully merged branch '{{ source.name }}' into '{{ target.name }}'.</p>
</div>
</div>
</div>
</div>
<div class="mt-4">
<button class="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded hover:bg-gray-200"
onclick="document.getElementById('merge-panel').innerHTML = ''">
Close
</button>
</div>
</div>
<script>
document.body.dispatchEvent(new CustomEvent('branch-updated'));
</script>

View File

@@ -1,94 +0,0 @@
{% if version_control.vcs_enabled and version_control.page_is_versioned %}
<div class="version-control-ui bg-white shadow-sm rounded-lg p-4 mb-4">
<!-- Branch Information -->
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-lg font-semibold">Version Control</h3>
{% if version_control.current_branch %}
<p class="text-sm text-gray-600">
Current Branch:
<span class="font-medium">{{ version_control.branch_name }}</span>
</p>
{% endif %}
</div>
<!-- Branch Selection -->
<div class="relative" x-data="{ open: false }">
<button @click="open = !open"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
Branch Actions
</button>
<div x-show="open"
@click.away="open = false"
class="absolute right-0 mt-2 py-2 w-48 bg-white rounded-lg shadow-xl z-50">
<!-- Create Branch -->
<button class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left"
hx-get="{% url 'history:branch-create' %}"
hx-target="#branch-form-container">
Create Branch
</button>
<!-- Switch Branch -->
{% if version_control.available_branches %}
<div class="border-t border-gray-100 my-2"></div>
{% for branch in version_control.available_branches %}
<button class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left"
hx-post="{% url 'history:switch-branch' %}"
hx-vals='{"branch": "{{ branch.name }}"}'
hx-target="body">
Switch to {{ branch.name }}
</button>
{% endfor %}
{% endif %}
</div>
</div>
</div>
<!-- Recent Changes -->
{% if version_control.recent_changes %}
<div class="mt-4">
<h4 class="text-sm font-semibold text-gray-700 mb-2">Recent Changes</h4>
<div class="space-y-2">
{% for change in version_control.recent_changes %}
<div class="bg-gray-50 p-2 rounded text-sm">
<div class="flex justify-between items-start">
<div>
<span class="font-medium">{{ change.description }}</span>
<p class="text-xs text-gray-500">
{{ change.created_at|date:"M d, Y H:i" }}
{% if change.created_by %}
by {{ change.created_by.username }}
{% endif %}
</p>
</div>
<span class="px-2 py-1 text-xs rounded
{% if change.status == 'applied' %}
bg-green-100 text-green-800
{% elif change.status == 'pending' %}
bg-yellow-100 text-yellow-800
{% elif change.status == 'failed' %}
bg-red-100 text-red-800
{% endif %}">
{{ change.status|title }}
</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Branch Form Container -->
<div id="branch-form-container"></div>
<!-- Merge Panel -->
<div id="merge-panel"></div>
</div>
<!-- Scripts -->
<script>
document.body.addEventListener('branch-switched', function(e) {
location.reload();
});
</script>
{% endif %}

View File

@@ -1,172 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Version Control Monitoring - ThrillWiki{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/monitoring.css' %}">
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-bold text-gray-900 mb-8">Version Control Monitoring</h1>
<!-- System Overview -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div class="bg-white rounded-lg shadow-sm p-6">
<h3 class="text-lg font-semibold mb-2">Total Branches</h3>
<p class="text-3xl font-bold text-blue-600">{{ metrics.total_branches }}</p>
<p class="text-sm text-gray-500 mt-2">{{ metrics.active_branches }} active</p>
</div>
<div class="bg-white rounded-lg shadow-sm p-6">
<h3 class="text-lg font-semibold mb-2">Total Changes</h3>
<p class="text-3xl font-bold text-green-600">{{ metrics.total_changes }}</p>
<p class="text-sm text-gray-500 mt-2">{{ metrics.pending_changes }} pending</p>
</div>
<div class="bg-white rounded-lg shadow-sm p-6">
<h3 class="text-lg font-semibold mb-2">Merge Success Rate</h3>
<p class="text-3xl font-bold text-indigo-600">{{ metrics.merge_success_rate }}%</p>
<p class="text-sm text-gray-500 mt-2">{{ metrics.conflicted_merges }} conflicts</p>
</div>
<div class="bg-white rounded-lg shadow-sm p-6">
<h3 class="text-lg font-semibold mb-2">System Health</h3>
<p class="text-3xl font-bold {% if metrics.system_health >= 90 %}text-green-600{% elif metrics.system_health >= 70 %}text-yellow-600{% else %}text-red-600{% endif %}">
{{ metrics.system_health }}%
</p>
<p class="text-sm text-gray-500 mt-2">Based on {{ metrics.health_checks }} checks</p>
</div>
</div>
<!-- Performance Metrics -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-8">
<h2 class="text-xl font-bold mb-4">Performance Metrics</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Operation Timing -->
<div>
<h3 class="text-lg font-semibold mb-3">Operation Timing (avg)</h3>
<ul class="space-y-2">
<li class="flex justify-between items-center">
<span class="text-gray-600">Branch Creation</span>
<span class="font-medium">{{ metrics.timing.branch_creation }}ms</span>
</li>
<li class="flex justify-between items-center">
<span class="text-gray-600">Branch Switch</span>
<span class="font-medium">{{ metrics.timing.branch_switch }}ms</span>
</li>
<li class="flex justify-between items-center">
<span class="text-gray-600">Merge Operation</span>
<span class="font-medium">{{ metrics.timing.merge }}ms</span>
</li>
</ul>
</div>
<!-- Database Metrics -->
<div>
<h3 class="text-lg font-semibold mb-3">Database Performance</h3>
<ul class="space-y-2">
<li class="flex justify-between items-center">
<span class="text-gray-600">Query Count (avg)</span>
<span class="font-medium">{{ metrics.database.query_count }}</span>
</li>
<li class="flex justify-between items-center">
<span class="text-gray-600">Query Time (avg)</span>
<span class="font-medium">{{ metrics.database.query_time }}ms</span>
</li>
<li class="flex justify-between items-center">
<span class="text-gray-600">Connection Pool</span>
<span class="font-medium">{{ metrics.database.pool_size }}/{{ metrics.database.max_pool }}</span>
</li>
</ul>
</div>
<!-- Cache Metrics -->
<div>
<h3 class="text-lg font-semibold mb-3">Cache Performance</h3>
<ul class="space-y-2">
<li class="flex justify-between items-center">
<span class="text-gray-600">Hit Rate</span>
<span class="font-medium">{{ metrics.cache.hit_rate }}%</span>
</li>
<li class="flex justify-between items-center">
<span class="text-gray-600">Miss Rate</span>
<span class="font-medium">{{ metrics.cache.miss_rate }}%</span>
</li>
<li class="flex justify-between items-center">
<span class="text-gray-600">Memory Usage</span>
<span class="font-medium">{{ metrics.cache.memory_usage }}MB</span>
</li>
</ul>
</div>
</div>
</div>
<!-- Error Tracking -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-8">
<h2 class="text-xl font-bold mb-4">Error Tracking</h2>
<div class="overflow-x-auto">
<table class="min-w-full">
<thead>
<tr>
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time</th>
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Operation</th>
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Message</th>
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for error in metrics.errors %}
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ error.timestamp }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ error.type }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ error.operation }}</td>
<td class="px-6 py-4 text-sm text-gray-500">{{ error.message }}</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {% if error.resolved %}bg-green-100 text-green-800{% else %}bg-red-100 text-red-800{% endif %}">
{{ error.resolved|yesno:"Resolved,Unresolved" }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Active Users -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-xl font-bold mb-4">Active Users</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-semibold mb-3">Current Operations</h3>
<ul class="space-y-2">
{% for operation in metrics.current_operations %}
<li class="flex justify-between items-center">
<span class="text-gray-600">{{ operation.user }}</span>
<span class="text-sm">{{ operation.action }}</span>
</li>
{% endfor %}
</ul>
</div>
<div>
<h3 class="text-lg font-semibold mb-3">Recent Activity</h3>
<ul class="space-y-2">
{% for activity in metrics.recent_activity %}
<li class="text-sm text-gray-600">
{{ activity.user }} {{ activity.action }} {{ activity.timestamp|timesince }} ago
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{% static 'js/monitoring.js' %}"></script>
{% endblock %}

View File

@@ -1,51 +0,0 @@
{% 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

@@ -1,102 +0,0 @@
{% 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

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

View File

@@ -1,27 +0,0 @@
{% 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

@@ -1,35 +0,0 @@
{% 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

@@ -1,33 +0,0 @@
<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

@@ -1,170 +0,0 @@
{% 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,53 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="version-control-panel p-4">
<div class="grid grid-cols-12 gap-4">
<!-- Left Sidebar -->
<div class="col-span-3 bg-white rounded-lg shadow p-4">
<div class="mb-4">
<h3 class="text-lg font-semibold mb-2">Branches</h3>
<button
class="w-full bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
hx-get="{% url 'history:branch-create' %}"
hx-target="#branch-form-container"
>
New Branch
</button>
</div>
<!-- Branch List -->
<div
id="branch-list"
hx-get="{% url 'history:branch-list' %}"
hx-trigger="load, branch-updated from:body">
<!-- Branch list will be loaded here -->
</div>
</div>
<!-- Main Content Area -->
<div class="col-span-9">
<!-- Branch Form Container -->
<div id="branch-form-container"></div>
<!-- History View -->
<div
id="history-view"
class="bg-white rounded-lg shadow p-4 mb-4"
hx-get="{% url 'history:history-view' %}?branch={{ current_branch }}"
hx-trigger="load, branch-selected from:body">
<!-- History will be loaded here -->
</div>
<!-- Merge Panel -->
<div
id="merge-panel"
class="bg-white rounded-lg shadow p-4"
hx-get="{% url 'history:merge-view' %}"
hx-trigger="merge-initiated from:body">
<!-- Merge interface will be loaded here -->
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,268 +0,0 @@
from django.test import TestCase
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from history_tracking.models import VersionBranch, ChangeSet
from history_tracking.managers import BranchManager, MergeStrategy
from parks.models import Park
class BranchManagerTests(TestCase):
def setUp(self):
self.park = Park.objects.create(
name='Test Park',
slug='test-park',
status='OPERATING'
)
self.content_type = ContentType.objects.get_for_model(Park)
self.manager = BranchManager()
self.main_branch = VersionBranch.objects.create(
name='main',
metadata={'type': 'default_branch'}
)
def test_create_branch(self):
"""Test branch creation with metadata"""
branch = self.manager.create_branch(
name='feature/test',
metadata={'type': 'feature', 'description': 'Test branch'}
)
self.assertEqual(branch.name, 'feature/test')
self.assertEqual(branch.metadata['type'], 'feature')
self.assertTrue(branch.is_active)
def test_get_active_branches(self):
"""Test retrieving only active branches"""
# Create some branches
feature_branch = self.manager.create_branch(
name='feature/active',
metadata={'type': 'feature'}
)
inactive_branch = self.manager.create_branch(
name='feature/inactive',
metadata={'type': 'feature'}
)
inactive_branch.is_active = False
inactive_branch.save()
active_branches = self.manager.get_active_branches()
self.assertIn(self.main_branch, active_branches)
self.assertIn(feature_branch, active_branches)
self.assertNotIn(inactive_branch, active_branches)
def test_get_branch_changes(self):
"""Test retrieving changes for a specific branch"""
# Create some changes in different branches
main_change = ChangeSet.objects.create(
branch=self.main_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Main Change'},
status='applied'
)
feature_branch = self.manager.create_branch(name='feature/test')
feature_change = ChangeSet.objects.create(
branch=feature_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Feature Change'},
status='applied'
)
main_changes = self.manager.get_branch_changes(self.main_branch)
feature_changes = self.manager.get_branch_changes(feature_branch)
self.assertIn(main_change, main_changes)
self.assertNotIn(feature_change, main_changes)
self.assertIn(feature_change, feature_changes)
self.assertNotIn(main_change, feature_changes)
def test_merge_branches(self):
"""Test merging changes between branches"""
# Create feature branch with changes
feature_branch = self.manager.create_branch(name='feature/test')
change = ChangeSet.objects.create(
branch=feature_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Updated Name'},
status='applied'
)
# Merge feature branch into main
self.manager.merge_branches(
source_branch=feature_branch,
target_branch=self.main_branch
)
# Verify changes were copied to main branch
main_changes = self.manager.get_branch_changes(self.main_branch)
self.assertEqual(main_changes.count(), 1)
merged_change = main_changes.first()
self.assertEqual(merged_change.data, change.data)
def test_branch_deletion(self):
"""Test branch deletion with cleanup"""
feature_branch = self.manager.create_branch(name='feature/delete')
ChangeSet.objects.create(
branch=feature_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Test Change'},
status='applied'
)
# Delete the branch
self.manager.delete_branch(feature_branch)
# Verify branch and its changes are gone
with self.assertRaises(VersionBranch.DoesNotExist):
VersionBranch.objects.get(name='feature/delete')
self.assertEqual(
ChangeSet.objects.filter(branch=feature_branch).count(),
0
)
class MergeStrategyTests(TestCase):
def setUp(self):
self.park = Park.objects.create(
name='Test Park',
slug='test-park',
status='OPERATING'
)
self.content_type = ContentType.objects.get_for_model(Park)
self.main_branch = VersionBranch.objects.create(
name='main',
metadata={'type': 'default_branch'}
)
self.feature_branch = VersionBranch.objects.create(
name='feature/test',
metadata={'type': 'feature'}
)
self.merge_strategy = MergeStrategy()
def test_simple_merge(self):
"""Test merging non-conflicting changes"""
# Create changes in feature branch
feature_changes = [
ChangeSet.objects.create(
branch=self.feature_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'New Name'},
status='applied',
applied_at=timezone.now()
),
ChangeSet.objects.create(
branch=self.feature_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'description': 'New Description'},
status='applied',
applied_at=timezone.now()
)
]
# Perform merge
with transaction.atomic():
conflicts = self.merge_strategy.merge(
source_branch=self.feature_branch,
target_branch=self.main_branch
)
self.assertEqual(conflicts, []) # No conflicts expected
main_changes = ChangeSet.objects.filter(branch=self.main_branch)
self.assertEqual(main_changes.count(), 2)
def test_conflict_detection(self):
"""Test detection of conflicting changes"""
# Create conflicting changes
ChangeSet.objects.create(
branch=self.main_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Main Name'},
status='applied',
applied_at=timezone.now()
)
ChangeSet.objects.create(
branch=self.feature_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Feature Name'},
status='applied',
applied_at=timezone.now()
)
# Attempt merge
with transaction.atomic():
conflicts = self.merge_strategy.merge(
source_branch=self.feature_branch,
target_branch=self.main_branch
)
self.assertTrue(conflicts) # Conflicts should be detected
conflict = conflicts[0]
self.assertEqual(conflict['field'], 'name')
self.assertEqual(conflict['target_value'], 'Main Name')
self.assertEqual(conflict['source_value'], 'Feature Name')
def test_merge_ordering(self):
"""Test that changes are merged in the correct order"""
# Create sequential changes
change1 = ChangeSet.objects.create(
branch=self.feature_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'First Change'},
status='applied',
applied_at=timezone.now()
)
change2 = ChangeSet.objects.create(
branch=self.feature_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Second Change'},
status='applied',
applied_at=timezone.now()
)
# Perform merge
with transaction.atomic():
self.merge_strategy.merge(
source_branch=self.feature_branch,
target_branch=self.main_branch
)
# Verify changes were merged in order
merged_changes = ChangeSet.objects.filter(
branch=self.main_branch
).order_by('applied_at')
self.assertEqual(
merged_changes[0].data['name'],
'First Change'
)
self.assertEqual(
merged_changes[1].data['name'],
'Second Change'
)
def test_merge_validation(self):
"""Test validation of merge operations"""
# Test merging inactive branch
self.feature_branch.is_active = False
self.feature_branch.save()
with self.assertRaises(ValidationError):
self.merge_strategy.merge(
source_branch=self.feature_branch,
target_branch=self.main_branch
)
# Test merging branch into itself
with self.assertRaises(ValidationError):
self.merge_strategy.merge(
source_branch=self.main_branch,
target_branch=self.main_branch
)

View File

@@ -1,173 +0,0 @@
from django.test import TestCase
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.utils import timezone
from history_tracking.models import VersionBranch, ChangeSet
from parks.models import Park
class VersionBranchTests(TestCase):
def setUp(self):
self.main_branch = VersionBranch.objects.create(
name='main',
metadata={'type': 'default_branch'}
)
self.feature_branch = VersionBranch.objects.create(
name='feature/new-layout',
metadata={'type': 'feature'}
)
def test_branch_creation(self):
"""Test that branch creation works with valid data"""
branch = VersionBranch.objects.create(
name='test-branch',
metadata={'type': 'test'}
)
self.assertEqual(branch.name, 'test-branch')
self.assertEqual(branch.metadata['type'], 'test')
self.assertTrue(branch.is_active)
self.assertIsNotNone(branch.created_at)
def test_invalid_branch_name(self):
"""Test that branch names are properly validated"""
with self.assertRaises(ValidationError):
VersionBranch.objects.create(name='', metadata={})
# Test overly long name
with self.assertRaises(ValidationError):
VersionBranch.objects.create(
name='a' * 256,
metadata={}
)
def test_branch_deactivation(self):
"""Test that branches can be deactivated"""
self.feature_branch.is_active = False
self.feature_branch.save()
branch = VersionBranch.objects.get(name='feature/new-layout')
self.assertFalse(branch.is_active)
def test_branch_metadata(self):
"""Test that branch metadata can be updated"""
metadata = {
'type': 'feature',
'description': 'New layout implementation',
'owner': 'test-user'
}
self.feature_branch.metadata = metadata
self.feature_branch.save()
branch = VersionBranch.objects.get(name='feature/new-layout')
self.assertEqual(branch.metadata, metadata)
class ChangeSetTests(TestCase):
def setUp(self):
self.main_branch = VersionBranch.objects.create(
name='main',
metadata={'type': 'default_branch'}
)
self.park = Park.objects.create(
name='Test Park',
slug='test-park',
status='OPERATING'
)
self.content_type = ContentType.objects.get_for_model(Park)
def test_changeset_creation(self):
"""Test that changeset creation works with valid data"""
changeset = ChangeSet.objects.create(
branch=self.main_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Updated Park Name'},
status='pending',
description='Update park name'
)
self.assertEqual(changeset.branch, self.main_branch)
self.assertEqual(changeset.content_type, self.content_type)
self.assertEqual(changeset.object_id, self.park.id)
self.assertEqual(changeset.status, 'pending')
def test_changeset_status_flow(self):
"""Test that changeset status transitions work correctly"""
changeset = ChangeSet.objects.create(
branch=self.main_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Updated Park Name'},
status='pending'
)
# Test status transition: pending -> applied
changeset.status = 'applied'
changeset.applied_at = timezone.now()
changeset.save()
updated_changeset = ChangeSet.objects.get(pk=changeset.pk)
self.assertEqual(updated_changeset.status, 'applied')
self.assertIsNotNone(updated_changeset.applied_at)
def test_invalid_changeset_status(self):
"""Test that invalid changeset statuses are rejected"""
with self.assertRaises(ValidationError):
ChangeSet.objects.create(
branch=self.main_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Updated Park Name'},
status='invalid_status'
)
def test_changeset_validation(self):
"""Test that changesets require valid branch and content object"""
# Test missing branch
with self.assertRaises(ValidationError):
ChangeSet.objects.create(
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Updated Park Name'},
status='pending'
)
# Test invalid content object
with self.assertRaises(ValidationError):
ChangeSet.objects.create(
branch=self.main_branch,
content_type=self.content_type,
object_id=99999, # Non-existent object
data={'name': 'Updated Park Name'},
status='pending'
)
def test_changeset_relationship_cascade(self):
"""Test that changesets are deleted when branch is deleted"""
changeset = ChangeSet.objects.create(
branch=self.main_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Updated Park Name'},
status='pending'
)
# Delete the branch
self.main_branch.delete()
# Verify changeset was deleted
with self.assertRaises(ChangeSet.DoesNotExist):
ChangeSet.objects.get(pk=changeset.pk)
def test_changeset_data_validation(self):
"""Test that changeset data must be valid JSON"""
changeset = ChangeSet.objects.create(
branch=self.main_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'valid': 'json_data'},
status='pending'
)
# Test invalid JSON data
with self.assertRaises(ValidationError):
changeset.data = "invalid_json"
changeset.save()

View File

@@ -1,223 +0,0 @@
from django.test import TestCase, Client
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from history_tracking.models import VersionBranch, ChangeSet
from parks.models import Park
User = get_user_model()
@override_settings(HTMX_ENABLED=True)
class VersionControlViewsTests(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_superuser(
username='admin',
email='admin@example.com',
password='testpass123'
)
self.client.login(username='admin', password='testpass123')
self.park = Park.objects.create(
name='Test Park',
slug='test-park',
status='OPERATING'
)
self.content_type = ContentType.objects.get_for_model(Park)
self.main_branch = VersionBranch.objects.create(
name='main',
metadata={'type': 'default_branch'}
)
self.feature_branch = VersionBranch.objects.create(
name='feature/test',
metadata={'type': 'feature'}
)
def test_version_control_panel(self):
"""Test rendering of version control panel"""
response = self.client.get(
reverse('version_control_panel'),
HTTP_HX_REQUEST='true',
HTTP_HX_TARGET='version-control-panel'
)
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed('history_tracking/includes/version_control_ui.html')
self.assertContains(response, 'main') # Should show main branch
self.assertContains(response, 'feature/test') # Should show feature branch
def test_create_branch(self):
"""Test branch creation through view"""
response = self.client.post(
reverse('create_branch'),
{
'name': 'feature/new',
'metadata': '{"type": "feature", "description": "New feature"}'
},
HTTP_HX_REQUEST='true'
)
self.assertEqual(response.status_code, 200)
self.assertTrue(
VersionBranch.objects.filter(name='feature/new').exists()
)
self.assertContains(response, 'Branch created successfully')
def test_switch_branch(self):
"""Test switching between branches"""
response = self.client.post(
reverse('switch_branch'),
{'branch_id': self.feature_branch.id},
HTTP_HX_REQUEST='true'
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Switched to branch')
self.assertContains(response, 'feature/test')
def test_merge_branch(self):
"""Test branch merging through view"""
# Create a change in feature branch
ChangeSet.objects.create(
branch=self.feature_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Updated Name'},
status='applied'
)
response = self.client.post(
reverse('merge_branch'),
{
'source_branch_id': self.feature_branch.id,
'target_branch_id': self.main_branch.id
},
HTTP_HX_REQUEST='true'
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Branch merged successfully')
# Verify changes were merged
main_changes = ChangeSet.objects.filter(branch=self.main_branch)
self.assertEqual(main_changes.count(), 1)
def test_merge_conflict_handling(self):
"""Test handling of merge conflicts"""
# Create conflicting changes
ChangeSet.objects.create(
branch=self.main_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Main Name'},
status='applied'
)
ChangeSet.objects.create(
branch=self.feature_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Feature Name'},
status='applied'
)
response = self.client.post(
reverse('merge_branch'),
{
'source_branch_id': self.feature_branch.id,
'target_branch_id': self.main_branch.id
},
HTTP_HX_REQUEST='true'
)
self.assertEqual(response.status_code, 409) # Conflict status
self.assertContains(response, 'Merge conflicts detected')
def test_view_history(self):
"""Test viewing version history"""
# Create some changes
change = ChangeSet.objects.create(
branch=self.main_branch,
content_type=self.content_type,
object_id=self.park.id,
data={'name': 'Updated Name'},
status='applied'
)
response = self.client.get(
reverse('version_history', kwargs={'pk': self.park.pk}),
HTTP_HX_REQUEST='true'
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Updated Name')
self.assertContains(response, str(change.created_at))
def test_branch_deletion(self):
"""Test branch deletion through view"""
response = self.client.post(
reverse('delete_branch'),
{'branch_id': self.feature_branch.id},
HTTP_HX_REQUEST='true'
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Branch deleted successfully')
self.assertFalse(
VersionBranch.objects.filter(id=self.feature_branch.id).exists()
)
def test_unauthorized_access(self):
"""Test that unauthorized users cannot access version control"""
self.client.logout()
response = self.client.get(
reverse('version_control_panel'),
HTTP_HX_REQUEST='true'
)
self.assertEqual(response.status_code, 302) # Redirect to login
def test_htmx_requirements(self):
"""Test that views require HTMX headers"""
# Try without HTMX headers
response = self.client.get(reverse('version_control_panel'))
self.assertEqual(response.status_code, 400)
self.assertContains(
response,
'This endpoint requires HTMX',
status_code=400
)
def test_branch_validation(self):
"""Test branch name validation in views"""
response = self.client.post(
reverse('create_branch'),
{
'name': '[AWS-SECRET-REMOVED]ts',
'metadata': '{}'
},
HTTP_HX_REQUEST='true'
)
self.assertEqual(response.status_code, 400)
self.assertContains(
response,
'Invalid branch name',
status_code=400
)
def test_branch_list_update(self):
"""Test that branch list updates after operations"""
response = self.client.get(
reverse('branch_list'),
HTTP_HX_REQUEST='true'
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'main')
self.assertContains(response, 'feature/test')
# Create new branch
new_branch = VersionBranch.objects.create(
name='feature/new',
metadata={'type': 'feature'}
)
# List should update
response = self.client.get(
reverse('branch_list'),
HTTP_HX_REQUEST='true'
)
self.assertContains(response, 'feature/new')

View File

@@ -1,50 +0,0 @@
from django.urls import path
from . import views, htmx_views
app_name = 'history_tracking'
urlpatterns = [
# 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'
),
# 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,149 +0,0 @@
from typing import Dict, Any, List, Optional, TypeVar, Type, cast
from django.core.exceptions import ValidationError
from .models import VersionBranch, ChangeSet
from django.utils import timezone
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser
from django.db.models import Model
UserModel = TypeVar('UserModel', bound=AbstractUser)
User = cast(Type[UserModel], get_user_model())
def _handle_source_target_resolution(change: ChangeSet) -> Dict[str, Any]:
resolved = {}
for record in change.historical_records.all():
resolved[f"{record.instance_type}_{record.instance_pk}"] = record
return resolved
def _handle_manual_resolution(
conflict_id: str,
source_change: ChangeSet,
manual_resolutions: Dict[str, str],
user: Optional[UserModel]
) -> Dict[str, Any]:
manual_content = manual_resolutions.get(conflict_id)
if not manual_content:
raise ValidationError(f"Manual resolution missing for conflict {conflict_id}")
resolved = {}
base_record = source_change.historical_records.first()
if base_record:
new_record = base_record.__class__(
**{
**base_record.__dict__,
'id': base_record.id,
'history_date': timezone.now(),
'history_user': user,
'history_change_reason': 'Manual conflict resolution',
'history_type': '~'
}
)
for field, value in manual_content.items():
setattr(new_record, field, value)
resolved[f"{new_record.instance_type}_{new_record.instance_pk}"] = new_record
return resolved
def resolve_conflicts(
source_branch: VersionBranch,
target_branch: VersionBranch,
resolutions: Dict[str, str],
manual_resolutions: Dict[str, str],
user: Optional[UserModel] = None
) -> ChangeSet:
"""
Resolve merge conflicts between branches
Args:
source_branch: Source branch of the merge
target_branch: Target branch of the merge
resolutions: Dict mapping conflict IDs to resolution type ('source', 'target', 'manual')
manual_resolutions: Dict mapping conflict IDs to manual resolution content
user: User performing the resolution
Returns:
ChangeSet: The changeset recording the conflict resolution
"""
if not resolutions:
raise ValidationError("No resolutions provided")
resolved_content = {}
for conflict_id, resolution_type in resolutions.items():
source_id, target_id = conflict_id.split('_')
source_change = ChangeSet.objects.get(pk=source_id)
target_change = ChangeSet.objects.get(pk=target_id)
if resolution_type == 'source':
resolved_content.update(_handle_source_target_resolution(source_change))
elif resolution_type == 'target':
resolved_content.update(_handle_source_target_resolution(target_change))
elif resolution_type == 'manual':
resolved_content.update(_handle_manual_resolution(
conflict_id, source_change, manual_resolutions, user
))
resolution_changeset = ChangeSet.objects.create(
branch=target_branch,
created_by=user,
description=f"Resolved conflicts from '{source_branch.name}'",
metadata={
'resolution_type': 'conflict_resolution',
'source_branch': source_branch.name,
'resolved_conflicts': list(resolutions.keys())
},
status='applied'
)
for record in resolved_content.values():
resolution_changeset.historical_records.add(record)
return resolution_changeset
def get_change_diff(change: ChangeSet) -> List[Dict[str, Any]]:
"""
Get a structured diff of changes in a changeset
Args:
change: The changeset to analyze
Returns:
List of diffs for each changed record
"""
diffs = []
for record in change.historical_records.all():
diff = {
'model': record.instance_type.__name__,
'id': record.instance_pk,
'type': record.history_type,
'date': record.history_date,
'user': record.history_user_display,
'changes': {}
}
if record.history_type == '~': # Modified
previous = record.prev_record
if previous:
diff['changes'] = record.diff_against_previous
elif record.history_type == '+': # Added
diff['changes'] = {
field: {'old': None, 'new': str(getattr(record, field))}
for field in record.__dict__
if not field.startswith('_') and field not in [
'history_date', 'history_id', 'history_type',
'history_user_id', 'history_change_reason'
]
}
elif record.history_type == '-': # Deleted
diff['changes'] = {
field: {'old': str(getattr(record, field)), 'new': None}
for field in record.__dict__
if not field.startswith('_') and field not in [
'history_date', 'history_id', 'history_type',
'history_user_id', 'history_change_reason'
]
}
diffs.append(diff)
return diffs

View File

@@ -1,144 +1,3 @@
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 django.shortcuts import render
from .models import VersionBranch, ChangeSet, VersionTag, HistoricalCommentThread
from .managers import ChangeTracker
from .comparison import ComparisonEngine
from .state_machine import ApprovalStateMachine
ITEMS_PER_PAGE = 20
@login_required
def version_comparison(request: HttpRequest) -> HttpResponse:
"""View for comparing different versions"""
versions = VersionTag.objects.all().order_by('-created_at')
version1_id = request.GET.get('version1')
version2_id = request.GET.get('version2')
page_number = request.GET.get('page', 1)
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)
# Get comparison results
engine = ComparisonEngine()
diff_result = engine.compute_enhanced_diff(version1, version2)
# 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'] = HistoricalCommentThread.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 = [
{
'name': 'Technical Review',
'required_roles': ['tech_reviewer']
},
{
'name': 'Final Approval',
'required_roles': ['approver']
}
]
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
})
@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:
messages.success(request, f"Successfully {decision}d changes")
else:
messages.error(request, "Failed to submit approval")
except Exception as e:
messages.error(request, f"Error processing approval: {str(e)}")
return render(request, 'history_tracking/approval_status.html', {
'changeset': changeset
})
# Create your views here.

View File

@@ -1,320 +0,0 @@
from django.views.generic import TemplateView
from django.contrib.admin.views.decorators import staff_member_required
from django.utils.decorators import method_decorator
from django.utils import timezone
from datetime import timedelta
from .models import VersionBranch, ChangeSet
from .monitoring import VersionControlMetrics
@method_decorator(staff_member_required, name='dispatch')
class MonitoringDashboardView(TemplateView):
template_name = 'history_tracking/monitoring_dashboard.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
metrics = self._collect_metrics()
context['metrics'] = metrics
return context
def _collect_metrics(self):
"""Collect all monitoring metrics"""
# Collect basic statistics
total_branches = VersionBranch.objects.count()
active_branches = VersionBranch.objects.filter(is_active=True).count()
total_changes = ChangeSet.objects.count()
pending_changes = ChangeSet.objects.filter(status='pending').count()
# Calculate merge success rate
last_week = timezone.now() - timedelta(days=7)
total_merges = ChangeSet.objects.filter(
created_at__gte=last_week,
status__in=['applied', 'conflict']
).count()
successful_merges = ChangeSet.objects.filter(
created_at__gte=last_week,
status='applied'
).count()
merge_success_rate = round(
(successful_merges / total_merges * 100) if total_merges > 0 else 100
)
# Get performance metrics
VersionControlMetrics.collect_performance_metrics()
perf_metrics = self._get_performance_metrics()
# Get error tracking data
errors = self._get_error_tracking()
# Get user activity
user_activity = self._get_user_activity()
return {
# System Overview
'total_branches': total_branches,
'active_branches': active_branches,
'total_changes': total_changes,
'pending_changes': pending_changes,
'merge_success_rate': merge_success_rate,
'conflicted_merges': ChangeSet.objects.filter(
status='conflict'
).count(),
'system_health': self._calculate_system_health(),
'health_checks': 5, # Number of health checks performed
# Performance Metrics
'timing': perf_metrics['timing'],
'database': perf_metrics['database'],
'cache': perf_metrics['cache'],
# Error Tracking
'errors': errors,
# User Activity
'current_operations': user_activity['current'],
'recent_activity': user_activity['recent']
}
def _get_performance_metrics(self):
"""Get detailed performance metrics"""
from django.db import connection
from django.core.cache import cache
# Calculate average operation timings
operation_times = {
'branch_creation': [],
'branch_switch': [],
'merge': []
}
for log in self._get_operation_logs():
if log['operation'] in operation_times:
operation_times[log['operation']].append(log['duration'])
timing = {
op: round(sum(times) / len(times), 2) if times else 0
for op, times in operation_times.items()
}
return {
'timing': timing,
'database': {
'query_count': len(connection.queries),
'query_time': round(
sum(float(q['time']) for q in connection.queries),
3
),
'pool_size': connection.pool_size if hasattr(connection, 'pool_size') else 'N/A',
'max_pool': connection.max_pool if hasattr(connection, 'max_pool') else 'N/A'
},
'cache': {
'hit_rate': round(
cache.get('version_control_cache_hits', 0) /
(cache.get('version_control_cache_hits', 0) +
cache.get('version_control_cache_misses', 1)) * 100,
1
),
'miss_rate': round(
cache.get('version_control_cache_misses', 0) /
(cache.get('version_control_cache_hits', 0) +
cache.get('version_control_cache_misses', 1)) * 100,
1
),
'memory_usage': round(
cache.get('version_control_memory_usage', 0) / 1024 / 1024,
2
)
}
}
def _get_error_tracking(self):
"""Get recent error tracking data"""
from django.conf import settings
import logging
logger = logging.getLogger('version_control')
errors = []
# Get last 10 error logs
if hasattr(logger, 'handlers'):
for handler in logger.handlers:
if isinstance(handler, logging.FileHandler):
try:
with open(handler.baseFilename, 'r') as f:
for line in f.readlines()[-10:]:
if '[ERROR]' in line:
errors.append(self._parse_error_log(line))
except FileNotFoundError:
pass
return errors
def _parse_error_log(self, log_line):
"""Parse error log line into structured data"""
import re
from datetime import datetime
pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) \[ERROR\] (.*)'
match = re.match(pattern, log_line)
if match:
timestamp_str, message = match.groups()
return {
'timestamp': datetime.strptime(
timestamp_str,
'%Y-%m-%d %H:%M:%S,%f'
),
'type': 'Error',
'operation': self._extract_operation(message),
'message': message,
'resolved': False
}
return None
def _extract_operation(self, message):
"""Extract operation type from error message"""
if 'branch' in message.lower():
return 'Branch Operation'
elif 'merge' in message.lower():
return 'Merge Operation'
elif 'changeset' in message.lower():
return 'Change Operation'
return 'Unknown Operation'
def _get_user_activity(self):
"""Get current and recent user activity"""
from django.contrib.auth import get_user_model
User = get_user_model()
# Get active sessions
from django.contrib.sessions.models import Session
current_sessions = Session.objects.filter(
expire_date__gte=timezone.now()
)
current_operations = []
for session in current_sessions:
try:
uid = session.get_decoded().get('_auth_user_id')
if uid:
user = User.objects.get(pk=uid)
current_operations.append({
'user': user.username,
'action': self._get_user_current_action(user)
})
except (User.DoesNotExist, KeyError):
continue
# Get recent activity
recent = ChangeSet.objects.select_related('user').order_by(
'-created_at'
)[:10]
recent_activity = [
{
'user': change.user.username if change.user else 'System',
'action': self._get_change_action(change),
'timestamp': change.created_at
}
for change in recent
]
return {
'current': current_operations,
'recent': recent_activity
}
def _get_user_current_action(self, user):
"""Get user's current action based on recent activity"""
last_change = ChangeSet.objects.filter(
user=user
).order_by('-created_at').first()
if last_change:
if (timezone.now() - last_change.created_at).seconds < 300: # 5 minutes
return self._get_change_action(last_change)
return 'Viewing'
def _get_change_action(self, change):
"""Get human-readable action from change"""
if change.status == 'applied':
return f'Applied changes to {change.content_object}'
elif change.status == 'pending':
return f'Started editing {change.content_object}'
elif change.status == 'conflict':
return f'Resolving conflicts on {change.content_object}'
return 'Unknown action'
def _calculate_system_health(self):
"""Calculate overall system health percentage"""
factors = {
'merge_success': self._get_merge_success_health(),
'performance': self._get_performance_health(),
'error_rate': self._get_error_rate_health()
}
return round(sum(factors.values()) / len(factors))
def _get_merge_success_health(self):
"""Calculate health based on merge success rate"""
last_week = timezone.now() - timedelta(days=7)
total_merges = ChangeSet.objects.filter(
created_at__gte=last_week,
status__in=['applied', 'conflict']
).count()
successful_merges = ChangeSet.objects.filter(
created_at__gte=last_week,
status='applied'
).count()
if total_merges == 0:
return 100
return round((successful_merges / total_merges) * 100)
def _get_performance_health(self):
"""Calculate health based on performance metrics"""
metrics = self._get_performance_metrics()
factors = [
100 if metrics['timing']['merge'] < 1000 else 50, # Under 1 second is healthy
100 if metrics['cache']['hit_rate'] > 80 else 50, # Over 80% cache hit rate is healthy
100 if metrics['database']['query_time'] < 0.5 else 50 # Under 0.5s query time is healthy
]
return round(sum(factors) / len(factors))
def _get_error_rate_health(self):
"""Calculate health based on error rate"""
last_day = timezone.now() - timedelta(days=1)
total_operations = ChangeSet.objects.filter(
created_at__gte=last_day
).count()
error_count = len([
e for e in self._get_error_tracking()
if e['timestamp'] >= last_day
])
if total_operations == 0:
return 100
error_rate = (error_count / total_operations) * 100
return round(100 - error_rate)
def _get_operation_logs(self):
"""Get operation timing logs"""
import json
from pathlib import Path
log_file = Path('logs/version_control_timing.log')
if not log_file.exists():
return []
logs = []
try:
with open(log_file, 'r') as f:
for line in f:
try:
logs.append(json.loads(line))
except json.JSONDecodeError:
continue
except Exception:
return []
return logs