mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 17:31:09 -05:00
Add version control system functionality with branch management, history tracking, and merge operations
This commit is contained in:
@@ -1,26 +1,9 @@
|
||||
# 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):
|
||||
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
|
||||
"""Register signals when the app is ready"""
|
||||
from . import signals # Import signals to register them
|
||||
|
||||
177
history_tracking/managers.py
Normal file
177
history_tracking/managers.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
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 .models import VersionBranch, VersionTag, ChangeSet
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class BranchManager:
|
||||
"""Manages version control branch operations"""
|
||||
|
||||
@transaction.atomic
|
||||
def create_branch(self, name: str, parent: Optional[VersionBranch] = None,
|
||||
user: Optional[User] = 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[User] = 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)
|
||||
|
||||
class ChangeTracker:
|
||||
"""Tracks and manages changes across the system"""
|
||||
|
||||
@transaction.atomic
|
||||
def record_change(self, instance: Any, change_type: str,
|
||||
branch: VersionBranch, user: Optional[User] = 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'))
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,220 @@
|
||||
# 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,14 +1,18 @@
|
||||
# history_tracking/models.py
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.auth import get_user_model
|
||||
from simple_history.models import HistoricalRecords
|
||||
from .mixins import HistoricalChangeMixin
|
||||
from typing import Any, Type, TypeVar, cast
|
||||
from typing import Any, Type, TypeVar, cast, Optional
|
||||
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):
|
||||
"""Abstract base class for models with history tracking"""
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
@@ -47,3 +51,124 @@ 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)
|
||||
|
||||
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)
|
||||
|
||||
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 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=[
|
||||
('pending', 'Pending'),
|
||||
('applied', 'Applied'),
|
||||
('failed', 'Failed'),
|
||||
('reverted', 'Reverted')
|
||||
],
|
||||
default='pending'
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
138
history_tracking/signals.py
Normal file
138
history_tracking/signals.py
Normal file
@@ -0,0 +1,138 @@
|
||||
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)
|
||||
@@ -0,0 +1,58 @@
|
||||
<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>
|
||||
@@ -0,0 +1,43 @@
|
||||
<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>
|
||||
@@ -0,0 +1,88 @@
|
||||
<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>
|
||||
@@ -0,0 +1,116 @@
|
||||
<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>
|
||||
@@ -0,0 +1,49 @@
|
||||
<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>
|
||||
@@ -0,0 +1,30 @@
|
||||
<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>
|
||||
@@ -0,0 +1,53 @@
|
||||
{% 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 %}
|
||||
22
history_tracking/urls.py
Normal file
22
history_tracking/urls.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'history'
|
||||
|
||||
urlpatterns = [
|
||||
# Main VCS interface
|
||||
path('vcs/', views.VersionControlPanel.as_view(), name='vcs-panel'),
|
||||
|
||||
# Branch operations
|
||||
path('vcs/branches/', views.BranchListView.as_view(), name='branch-list'),
|
||||
path('vcs/branches/create/', views.BranchCreateView.as_view(), name='branch-create'),
|
||||
|
||||
# History views
|
||||
path('vcs/history/', views.HistoryView.as_view(), name='history-view'),
|
||||
|
||||
# Merge operations
|
||||
path('vcs/merge/', views.MergeView.as_view(), name='merge-view'),
|
||||
|
||||
# Tag operations
|
||||
path('vcs/tags/create/', views.TagCreateView.as_view(), name='tag-create'),
|
||||
]
|
||||
139
history_tracking/utils.py
Normal file
139
history_tracking/utils.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from typing import Dict, Any, List, Optional
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import VersionBranch, ChangeSet
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
def resolve_conflicts(
|
||||
source_branch: VersionBranch,
|
||||
target_branch: VersionBranch,
|
||||
resolutions: Dict[str, str],
|
||||
manual_resolutions: Dict[str, str],
|
||||
user: Optional[User] = 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':
|
||||
# Use source branch version
|
||||
for record in source_change.historical_records.all():
|
||||
resolved_content[f"{record.instance_type}_{record.instance_pk}"] = record
|
||||
|
||||
elif resolution_type == 'target':
|
||||
# Use target branch version
|
||||
for record in target_change.historical_records.all():
|
||||
resolved_content[f"{record.instance_type}_{record.instance_pk}"] = record
|
||||
|
||||
elif resolution_type == 'manual':
|
||||
# Use manual resolution
|
||||
manual_content = manual_resolutions.get(conflict_id)
|
||||
if not manual_content:
|
||||
raise ValidationError(f"Manual resolution missing for conflict {conflict_id}")
|
||||
|
||||
# Create new historical record with manual content
|
||||
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': '~'
|
||||
}
|
||||
)
|
||||
# Apply manual changes
|
||||
for field, value in manual_content.items():
|
||||
setattr(new_record, field, value)
|
||||
resolved_content[f"{new_record.instance_type}_{new_record.instance_pk}"] = new_record
|
||||
|
||||
# Create resolution changeset
|
||||
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'
|
||||
)
|
||||
|
||||
# Add resolved records to changeset
|
||||
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
|
||||
@@ -1,3 +1,237 @@
|
||||
from django.shortcuts import render
|
||||
from django.views.generic import TemplateView, View
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.template.loader import render_to_string
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
|
||||
# Create your views here.
|
||||
from .models import VersionBranch, VersionTag, ChangeSet
|
||||
from .managers import BranchManager, ChangeTracker, MergeStrategy
|
||||
|
||||
class VersionControlPanel(LoginRequiredMixin, TemplateView):
|
||||
"""Main version control interface"""
|
||||
template_name = 'history_tracking/version_control_panel.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
branch_manager = BranchManager()
|
||||
|
||||
context.update({
|
||||
'branches': branch_manager.list_branches(),
|
||||
'current_branch': self.request.GET.get('branch'),
|
||||
})
|
||||
return context
|
||||
|
||||
class BranchListView(LoginRequiredMixin, View):
|
||||
"""HTMX view for branch list"""
|
||||
|
||||
def get(self, request):
|
||||
branch_manager = BranchManager()
|
||||
branches = branch_manager.list_branches()
|
||||
|
||||
content = render_to_string(
|
||||
'history_tracking/components/branch_list.html',
|
||||
{'branches': branches},
|
||||
request=request
|
||||
)
|
||||
return HttpResponse(content)
|
||||
|
||||
class HistoryView(LoginRequiredMixin, View):
|
||||
"""HTMX view for change history"""
|
||||
|
||||
def get(self, request):
|
||||
branch_name = request.GET.get('branch')
|
||||
if not branch_name:
|
||||
return HttpResponse("No branch selected")
|
||||
|
||||
branch = get_object_or_404(VersionBranch, name=branch_name)
|
||||
tracker = ChangeTracker()
|
||||
changes = tracker.get_changes(branch)
|
||||
|
||||
content = render_to_string(
|
||||
'history_tracking/components/history_view.html',
|
||||
{'changes': changes},
|
||||
request=request
|
||||
)
|
||||
return HttpResponse(content)
|
||||
|
||||
class MergeView(LoginRequiredMixin, View):
|
||||
"""HTMX view for merge operations"""
|
||||
|
||||
def get(self, request):
|
||||
source = request.GET.get('source')
|
||||
target = request.GET.get('target')
|
||||
|
||||
if not (source and target):
|
||||
return HttpResponse("Source and target branches required")
|
||||
|
||||
content = render_to_string(
|
||||
'history_tracking/components/merge_panel.html',
|
||||
{
|
||||
'source': source,
|
||||
'target': target
|
||||
},
|
||||
request=request
|
||||
)
|
||||
return HttpResponse(content)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
source_name = request.POST.get('source')
|
||||
target_name = request.POST.get('target')
|
||||
|
||||
if not (source_name and target_name):
|
||||
return JsonResponse({
|
||||
'error': 'Source and target branches required'
|
||||
}, status=400)
|
||||
|
||||
try:
|
||||
source = get_object_or_404(VersionBranch, name=source_name)
|
||||
target = get_object_or_404(VersionBranch, name=target_name)
|
||||
|
||||
branch_manager = BranchManager()
|
||||
success, conflicts = branch_manager.merge_branches(
|
||||
source=source,
|
||||
target=target,
|
||||
user=request.user
|
||||
)
|
||||
|
||||
if success:
|
||||
content = render_to_string(
|
||||
'history_tracking/components/merge_success.html',
|
||||
{'source': source, 'target': target},
|
||||
request=request
|
||||
)
|
||||
return HttpResponse(content)
|
||||
else:
|
||||
content = render_to_string(
|
||||
'history_tracking/components/merge_conflicts.html',
|
||||
{
|
||||
'source': source,
|
||||
'target': target,
|
||||
'conflicts': conflicts
|
||||
},
|
||||
request=request
|
||||
)
|
||||
return HttpResponse(content)
|
||||
|
||||
except ValidationError as e:
|
||||
return JsonResponse({'error': str(e)}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse(
|
||||
{'error': 'Merge failed. Please try again.'},
|
||||
status=500
|
||||
)
|
||||
|
||||
class BranchCreateView(LoginRequiredMixin, View):
|
||||
"""HTMX view for branch creation"""
|
||||
|
||||
def get(self, request):
|
||||
content = render_to_string(
|
||||
'history_tracking/components/branch_create.html',
|
||||
request=request
|
||||
)
|
||||
return HttpResponse(content)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
name = request.POST.get('name')
|
||||
parent_name = request.POST.get('parent')
|
||||
|
||||
if not name:
|
||||
return JsonResponse({'error': 'Branch name required'}, status=400)
|
||||
|
||||
try:
|
||||
branch_manager = BranchManager()
|
||||
parent = None
|
||||
if parent_name:
|
||||
parent = get_object_or_404(VersionBranch, name=parent_name)
|
||||
|
||||
branch = branch_manager.create_branch(
|
||||
name=name,
|
||||
parent=parent,
|
||||
user=request.user
|
||||
)
|
||||
|
||||
content = render_to_string(
|
||||
'history_tracking/components/branch_item.html',
|
||||
{'branch': branch},
|
||||
request=request
|
||||
)
|
||||
return HttpResponse(content)
|
||||
|
||||
except ValidationError as e:
|
||||
return JsonResponse({'error': str(e)}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse(
|
||||
{'error': 'Branch creation failed. Please try again.'},
|
||||
status=500
|
||||
)
|
||||
|
||||
class TagCreateView(LoginRequiredMixin, View):
|
||||
"""HTMX view for version tagging"""
|
||||
|
||||
def get(self, request):
|
||||
branch_name = request.GET.get('branch')
|
||||
if not branch_name:
|
||||
return HttpResponse("Branch required")
|
||||
|
||||
content = render_to_string(
|
||||
'history_tracking/components/tag_create.html',
|
||||
{'branch_name': branch_name},
|
||||
request=request
|
||||
)
|
||||
return HttpResponse(content)
|
||||
|
||||
@transaction.atomic
|
||||
def post(self, request):
|
||||
name = request.POST.get('name')
|
||||
branch_name = request.POST.get('branch')
|
||||
|
||||
if not (name and branch_name):
|
||||
return JsonResponse(
|
||||
{'error': 'Tag name and branch required'},
|
||||
status=400
|
||||
)
|
||||
|
||||
try:
|
||||
branch = get_object_or_404(VersionBranch, name=branch_name)
|
||||
|
||||
# Get latest historical record for the branch
|
||||
latest_change = ChangeSet.objects.filter(
|
||||
branch=branch,
|
||||
status='applied'
|
||||
).latest('created_at')
|
||||
|
||||
if not latest_change:
|
||||
return JsonResponse(
|
||||
{'error': 'No changes to tag'},
|
||||
status=400
|
||||
)
|
||||
|
||||
tag = VersionTag.objects.create(
|
||||
name=name,
|
||||
branch=branch,
|
||||
historical_record=latest_change.historical_records.latest('history_date'),
|
||||
created_by=request.user,
|
||||
metadata={
|
||||
'tagged_at': timezone.now().isoformat(),
|
||||
'changeset': latest_change.pk
|
||||
}
|
||||
)
|
||||
|
||||
content = render_to_string(
|
||||
'history_tracking/components/tag_item.html',
|
||||
{'tag': tag},
|
||||
request=request
|
||||
)
|
||||
return HttpResponse(content)
|
||||
|
||||
except ValidationError as e:
|
||||
return JsonResponse({'error': str(e)}, status=400)
|
||||
except Exception as e:
|
||||
return JsonResponse(
|
||||
{'error': 'Tag creation failed. Please try again.'},
|
||||
status=500
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user