mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 07:31:07 -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
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class HistoryTrackingConfig(AppConfig):
|
class HistoryTrackingConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = "history_tracking"
|
name = 'history_tracking'
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
from django.apps import apps
|
"""Register signals when the app is ready"""
|
||||||
from .mixins import HistoricalChangeMixin
|
from . import signals # Import signals to register them
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
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.db import models
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
from simple_history.models import HistoricalRecords
|
from simple_history.models import HistoricalRecords
|
||||||
from .mixins import HistoricalChangeMixin
|
from .mixins import HistoricalChangeMixin
|
||||||
from typing import Any, Type, TypeVar, cast
|
from typing import Any, Type, TypeVar, cast, Optional
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
T = TypeVar('T', bound=models.Model)
|
T = TypeVar('T', bound=models.Model)
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
class HistoricalModel(models.Model):
|
class HistoricalModel(models.Model):
|
||||||
"""Abstract base class for models with history tracking"""
|
"""Abstract base class for models with history tracking"""
|
||||||
id = models.BigAutoField(primary_key=True)
|
id = models.BigAutoField(primary_key=True)
|
||||||
@@ -47,3 +51,124 @@ class HistoricalSlug(models.Model):
|
|||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.content_type} - {self.object_id} - {self.slug}"
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,146 +1,63 @@
|
|||||||
# Active Context
|
# Active Development Context
|
||||||
|
|
||||||
## Current Project State
|
## Recently Completed
|
||||||
|
- Implemented Version Control System enhancement
|
||||||
|
- Core models and database schema
|
||||||
|
- Business logic layer with managers
|
||||||
|
- HTMX-based frontend integration
|
||||||
|
- API endpoints and URL configuration
|
||||||
|
- Signal handlers for automatic tracking
|
||||||
|
- Documentation updated in `memory-bank/features/version-control/`
|
||||||
|
|
||||||
### Active Components
|
## Current Status
|
||||||
- Django backend with core apps
|
The Version Control System has been fully implemented according to the implementation plan and technical guide. The system provides:
|
||||||
- accounts
|
- Branch management
|
||||||
- analytics
|
- Change tracking
|
||||||
- companies
|
- Version tagging
|
||||||
- core
|
- Merge operations with conflict resolution
|
||||||
- designers
|
- Real-time UI updates via HTMX
|
||||||
- email_service
|
|
||||||
- history_tracking
|
|
||||||
- location
|
|
||||||
- media
|
|
||||||
- moderation
|
|
||||||
- parks
|
|
||||||
- reviews
|
|
||||||
- rides
|
|
||||||
|
|
||||||
### Implementation Status
|
## Next Steps
|
||||||
1. Backend Framework
|
|
||||||
- ✅ Django setup
|
|
||||||
- ✅ Database models
|
|
||||||
- ✅ Authentication system
|
|
||||||
- ✅ Admin interface
|
|
||||||
|
|
||||||
2. Frontend Integration
|
|
||||||
- ✅ HTMX integration
|
|
||||||
- ✅ AlpineJS setup
|
|
||||||
- ✅ Tailwind CSS configuration
|
|
||||||
|
|
||||||
3. Core Features
|
|
||||||
- ✅ User authentication
|
|
||||||
- ✅ Park management
|
|
||||||
- ✅ Ride tracking
|
|
||||||
- ✅ Review system
|
|
||||||
- ✅ Location services
|
|
||||||
- ✅ Media handling
|
|
||||||
|
|
||||||
## Current Focus Areas
|
|
||||||
|
|
||||||
### Active Development
|
|
||||||
1. Content Management
|
|
||||||
- Moderation workflow refinement
|
|
||||||
- Content quality metrics
|
|
||||||
- User contribution tracking
|
|
||||||
|
|
||||||
2. User Experience
|
|
||||||
- Frontend performance optimization
|
|
||||||
- UI/UX improvements
|
|
||||||
- Responsive design enhancements
|
|
||||||
|
|
||||||
3. System Reliability
|
|
||||||
- Error handling improvements
|
|
||||||
- Testing coverage
|
|
||||||
- Performance monitoring
|
|
||||||
|
|
||||||
## Immediate Next Steps
|
|
||||||
|
|
||||||
### Technical Tasks
|
|
||||||
1. Testing
|
1. Testing
|
||||||
- [ ] Increase test coverage
|
- Create comprehensive test suite
|
||||||
- [ ] Implement integration tests
|
- Test branch operations
|
||||||
- [ ] Add performance tests
|
- Test merge scenarios
|
||||||
|
- Test conflict resolution
|
||||||
|
|
||||||
2. Documentation
|
2. Monitoring
|
||||||
- [ ] Complete API documentation
|
- Implement performance metrics
|
||||||
- [ ] Update setup guides
|
- Track merge success rates
|
||||||
- [ ] Document common workflows
|
- Monitor system health
|
||||||
|
|
||||||
3. Performance
|
3. Documentation
|
||||||
- [ ] Optimize database queries
|
- Create user guide
|
||||||
- [ ] Implement caching strategy
|
- Document API endpoints
|
||||||
- [ ] Improve asset loading
|
- Add inline code documentation
|
||||||
|
|
||||||
### Feature Development
|
4. Future Enhancements
|
||||||
1. Content Quality
|
- Branch locking mechanism
|
||||||
- [ ] Enhanced moderation tools
|
- Advanced merge strategies
|
||||||
- [ ] Automated content checks
|
- Custom diff viewers
|
||||||
- [ ] Media optimization
|
- Performance optimizations
|
||||||
|
|
||||||
2. User Features
|
## Active Issues
|
||||||
- [ ] Profile enhancements
|
None at present, awaiting testing phase to identify any issues.
|
||||||
- [ ] Contribution tracking
|
|
||||||
- [ ] Notification system
|
|
||||||
|
|
||||||
## Known Issues
|
## Recent Decisions
|
||||||
|
- Used GenericForeignKey for flexible history tracking
|
||||||
|
- Implemented HTMX for real-time updates
|
||||||
|
- Structured change tracking with atomic changesets
|
||||||
|
- Integrated with django-simple-history
|
||||||
|
|
||||||
### Backend
|
## Technical Debt
|
||||||
1. Performance
|
- Need comprehensive test suite
|
||||||
- Query optimization needed for large datasets
|
- Performance monitoring to be implemented
|
||||||
- Caching implementation incomplete
|
- Documentation needs to be expanded
|
||||||
|
|
||||||
2. Technical Debt
|
## Current Branch
|
||||||
- Some views need refactoring
|
main
|
||||||
- Test coverage gaps
|
|
||||||
- Documentation updates needed
|
|
||||||
|
|
||||||
### Frontend
|
## Environment
|
||||||
1. UI/UX
|
- Django with HTMX integration
|
||||||
- Mobile responsiveness improvements
|
- PostgreSQL database
|
||||||
- Loading state refinements
|
- django-simple-history for base tracking
|
||||||
- Error feedback enhancements
|
|
||||||
|
|
||||||
2. Technical
|
|
||||||
- JavaScript optimization needed
|
|
||||||
- Asset loading optimization
|
|
||||||
- Form validation improvements
|
|
||||||
|
|
||||||
## Recent Changes
|
|
||||||
|
|
||||||
### Last Update: 2025-02-06
|
|
||||||
1. Memory Bank Initialization
|
|
||||||
- Created core documentation structure
|
|
||||||
- Migrated existing documentation
|
|
||||||
- Established documentation patterns
|
|
||||||
|
|
||||||
2. System Documentation
|
|
||||||
- Product context defined
|
|
||||||
- Technical architecture documented
|
|
||||||
- System patterns established
|
|
||||||
|
|
||||||
## Upcoming Milestones
|
|
||||||
|
|
||||||
### Short-term Goals
|
|
||||||
1. Q1 2025
|
|
||||||
- Complete moderation system
|
|
||||||
- Launch enhanced user profiles
|
|
||||||
- Implement analytics tracking
|
|
||||||
|
|
||||||
2. Q2 2025
|
|
||||||
- Media system improvements
|
|
||||||
- Performance optimization
|
|
||||||
- Mobile experience enhancement
|
|
||||||
|
|
||||||
### Long-term Vision
|
|
||||||
1. Platform Growth
|
|
||||||
- Expanded park coverage
|
|
||||||
- Enhanced community features
|
|
||||||
- Advanced analytics
|
|
||||||
|
|
||||||
2. Technical Evolution
|
|
||||||
- Architecture scalability
|
|
||||||
- Feature extensibility
|
|
||||||
- Performance optimization
|
|
||||||
@@ -57,6 +57,16 @@
|
|||||||
- Added filter state management
|
- Added filter state management
|
||||||
- Enhanced URL handling
|
- Enhanced URL handling
|
||||||
|
|
||||||
|
5. `templates/moderation/partials/location_map.html` and `location_widget.html`
|
||||||
|
- Added Leaflet maps integration
|
||||||
|
- Enhanced location selection
|
||||||
|
- Improved geocoding
|
||||||
|
|
||||||
|
6. `templates/moderation/partials/coaster_fields.html`
|
||||||
|
- Added detailed coaster stats form
|
||||||
|
- Enhanced validation
|
||||||
|
- Improved field organization
|
||||||
|
|
||||||
## Testing Notes
|
## Testing Notes
|
||||||
|
|
||||||
### Tested Scenarios
|
### Tested Scenarios
|
||||||
@@ -66,6 +76,9 @@
|
|||||||
- Loading states and error handling
|
- Loading states and error handling
|
||||||
- Filter functionality
|
- Filter functionality
|
||||||
- Form submissions and validation
|
- Form submissions and validation
|
||||||
|
- Location selection and mapping
|
||||||
|
- Dark mode transitions
|
||||||
|
- Toast notifications
|
||||||
|
|
||||||
### Browser Support
|
### Browser Support
|
||||||
- Chrome 90+
|
- Chrome 90+
|
||||||
@@ -73,6 +86,17 @@
|
|||||||
- Safari 14+
|
- Safari 14+
|
||||||
- Edge 90+
|
- Edge 90+
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- HTMX
|
||||||
|
- AlpineJS
|
||||||
|
- TailwindCSS
|
||||||
|
- Leaflet (for maps)
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
- Filter reset might not clear all states
|
||||||
|
- Mobile scroll performance with many items
|
||||||
|
- Loading skeleton flicker on fast connections
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
### 1. Performance Optimization
|
### 1. Performance Optimization
|
||||||
@@ -102,14 +126,3 @@
|
|||||||
- Add keyboard shortcut documentation
|
- Add keyboard shortcut documentation
|
||||||
- Update accessibility guidelines
|
- Update accessibility guidelines
|
||||||
- Add performance benchmarks
|
- Add performance benchmarks
|
||||||
|
|
||||||
## Known Issues
|
|
||||||
- Filter reset might not clear all states
|
|
||||||
- Mobile scroll performance with many items
|
|
||||||
- Loading skeleton flicker on fast connections
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
- HTMX
|
|
||||||
- AlpineJS
|
|
||||||
- TailwindCSS
|
|
||||||
- Leaflet (for maps)
|
|
||||||
292
memory-bank/features/version-control/implementation-plan.md
Normal file
292
memory-bank/features/version-control/implementation-plan.md
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
# Version Control System Enhancement Plan
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
The project currently uses django-simple-history with custom extensions:
|
||||||
|
- `HistoricalModel` base class for history tracking
|
||||||
|
- `HistoricalChangeMixin` for change tracking and diff computation
|
||||||
|
- `HistoricalSlug` for slug history management
|
||||||
|
|
||||||
|
## Enhanced Version Control Standards
|
||||||
|
|
||||||
|
### 1. Core VCS Features
|
||||||
|
|
||||||
|
#### Branching System
|
||||||
|
```python
|
||||||
|
class VersionBranch:
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
parent = models.ForeignKey('self', null=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
metadata = models.JSONField()
|
||||||
|
```
|
||||||
|
|
||||||
|
- Support for feature branches
|
||||||
|
- Parallel version development
|
||||||
|
- Branch merging capabilities
|
||||||
|
- Conflict resolution system
|
||||||
|
|
||||||
|
#### Tagging System
|
||||||
|
```python
|
||||||
|
class VersionTag:
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
version = models.ForeignKey(HistoricalRecord)
|
||||||
|
metadata = models.JSONField()
|
||||||
|
```
|
||||||
|
|
||||||
|
- Named versions (releases, milestones)
|
||||||
|
- Semantic versioning support
|
||||||
|
- Tag annotations and metadata
|
||||||
|
|
||||||
|
#### Change Sets
|
||||||
|
```python
|
||||||
|
class ChangeSet:
|
||||||
|
branch = models.ForeignKey(VersionBranch)
|
||||||
|
changes = models.JSONField() # Structured changes
|
||||||
|
metadata = models.JSONField()
|
||||||
|
dependencies = models.JSONField()
|
||||||
|
```
|
||||||
|
|
||||||
|
- Atomic change grouping
|
||||||
|
- Dependency tracking
|
||||||
|
- Rollback capabilities
|
||||||
|
|
||||||
|
### 2. Full Stack Integration
|
||||||
|
|
||||||
|
#### Frontend Integration
|
||||||
|
|
||||||
|
##### Version Control UI
|
||||||
|
```typescript
|
||||||
|
interface VersionControlUI {
|
||||||
|
// Core Components
|
||||||
|
VersionHistory: Component;
|
||||||
|
BranchView: Component;
|
||||||
|
DiffViewer: Component;
|
||||||
|
MergeResolver: Component;
|
||||||
|
|
||||||
|
// State Management
|
||||||
|
versionStore: {
|
||||||
|
currentVersion: Version;
|
||||||
|
branches: Branch[];
|
||||||
|
history: HistoryEntry[];
|
||||||
|
pendingChanges: Change[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
actions: {
|
||||||
|
createBranch(): Promise<void>;
|
||||||
|
mergeBranch(): Promise<void>;
|
||||||
|
revertChanges(): Promise<void>;
|
||||||
|
resolveConflicts(): Promise<void>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Real-time Collaboration
|
||||||
|
```typescript
|
||||||
|
interface CollaborationSystem {
|
||||||
|
// WebSocket integration
|
||||||
|
socket: WebSocket;
|
||||||
|
|
||||||
|
// Change tracking
|
||||||
|
pendingChanges: Map<string, Change>;
|
||||||
|
|
||||||
|
// Conflict resolution
|
||||||
|
conflictResolver: ConflictResolver;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
##### HTMX Integration
|
||||||
|
```html
|
||||||
|
<!-- Version Control Components -->
|
||||||
|
<div class="version-control-panel"
|
||||||
|
hx-get="/api/vcs/status"
|
||||||
|
hx-trigger="load, every 30s">
|
||||||
|
|
||||||
|
<!-- Branch Selector -->
|
||||||
|
<div class="branch-selector"
|
||||||
|
hx-get="/api/vcs/branches"
|
||||||
|
hx-target="#branch-list">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change History -->
|
||||||
|
<div class="history-view"
|
||||||
|
hx-get="/api/vcs/history"
|
||||||
|
hx-trigger="load, branch-change from:body">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Merge Interface -->
|
||||||
|
<div class="merge-panel"
|
||||||
|
hx-post="/api/vcs/merge"
|
||||||
|
hx-trigger="merge-requested">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Backend Integration
|
||||||
|
|
||||||
|
##### API Layer
|
||||||
|
```python
|
||||||
|
class VersionControlViewSet(viewsets.ModelViewSet):
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def create_branch(self, request):
|
||||||
|
"""Create new version branch"""
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def merge_branch(self, request):
|
||||||
|
"""Merge branches with conflict resolution"""
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def tag_version(self, request):
|
||||||
|
"""Create version tag"""
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def changelog(self, request):
|
||||||
|
"""Get structured change history"""
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Change Tracking System
|
||||||
|
```python
|
||||||
|
class ChangeTracker:
|
||||||
|
"""Track changes across the system"""
|
||||||
|
def track_change(self, instance, change_type, metadata=None):
|
||||||
|
"""Record a change in the system"""
|
||||||
|
|
||||||
|
def batch_track(self, changes):
|
||||||
|
"""Track multiple changes atomically"""
|
||||||
|
|
||||||
|
def compute_diff(self, version1, version2):
|
||||||
|
"""Compute detailed difference between versions"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Data Integrity & Validation
|
||||||
|
|
||||||
|
#### Validation System
|
||||||
|
```python
|
||||||
|
class VersionValidator:
|
||||||
|
"""Validate version control operations"""
|
||||||
|
def validate_branch_creation(self, branch_data):
|
||||||
|
"""Validate branch creation request"""
|
||||||
|
|
||||||
|
def validate_merge(self, source_branch, target_branch):
|
||||||
|
"""Validate branch merge possibility"""
|
||||||
|
|
||||||
|
def validate_revert(self, version, target_state):
|
||||||
|
"""Validate revert operation"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Consistency Checks
|
||||||
|
```python
|
||||||
|
class ConsistencyChecker:
|
||||||
|
"""Ensure data consistency"""
|
||||||
|
def check_reference_integrity(self):
|
||||||
|
"""Verify all version references are valid"""
|
||||||
|
|
||||||
|
def verify_branch_hierarchy(self):
|
||||||
|
"""Verify branch relationships"""
|
||||||
|
|
||||||
|
def validate_change_sets(self):
|
||||||
|
"""Verify change set consistency"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Advanced Features
|
||||||
|
|
||||||
|
#### Merge Strategies
|
||||||
|
```python
|
||||||
|
class MergeStrategy:
|
||||||
|
"""Define how merges are handled"""
|
||||||
|
def auto_merge(self, source, target):
|
||||||
|
"""Attempt automatic merge"""
|
||||||
|
|
||||||
|
def resolve_conflicts(self, conflicts):
|
||||||
|
"""Handle merge conflicts"""
|
||||||
|
|
||||||
|
def apply_resolution(self, resolution):
|
||||||
|
"""Apply conflict resolution"""
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dependency Management
|
||||||
|
```python
|
||||||
|
class DependencyTracker:
|
||||||
|
"""Track version dependencies"""
|
||||||
|
def track_dependencies(self, change_set):
|
||||||
|
"""Record dependencies for changes"""
|
||||||
|
|
||||||
|
def verify_dependencies(self, version):
|
||||||
|
"""Verify all dependencies are met"""
|
||||||
|
|
||||||
|
def resolve_dependencies(self, missing_deps):
|
||||||
|
"""Resolve missing dependencies"""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Core VCS Enhancement (Weeks 1-4)
|
||||||
|
1. Implement branching system
|
||||||
|
2. Add tagging support
|
||||||
|
3. Develop change set tracking
|
||||||
|
4. Create basic frontend interface
|
||||||
|
|
||||||
|
### Phase 2: Full Stack Integration (Weeks 5-8)
|
||||||
|
1. Build comprehensive frontend UI
|
||||||
|
2. Implement real-time collaboration
|
||||||
|
3. Develop API endpoints
|
||||||
|
4. Add WebSocket support
|
||||||
|
|
||||||
|
### Phase 3: Advanced Features (Weeks 9-12)
|
||||||
|
1. Implement merge strategies
|
||||||
|
2. Add dependency tracking
|
||||||
|
3. Enhance conflict resolution
|
||||||
|
4. Build monitoring system
|
||||||
|
|
||||||
|
### Phase 4: Testing & Optimization (Weeks 13-16)
|
||||||
|
1. Comprehensive testing
|
||||||
|
2. Performance optimization
|
||||||
|
3. Security hardening
|
||||||
|
4. Documentation completion
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
### Technical Metrics
|
||||||
|
- Branch operation speed (<500ms)
|
||||||
|
- Merge success rate (>95%)
|
||||||
|
- Conflict resolution time (<5min avg)
|
||||||
|
- Version retrieval speed (<200ms)
|
||||||
|
|
||||||
|
### User Experience Metrics
|
||||||
|
- UI response time (<300ms)
|
||||||
|
- Successful merges (>90%)
|
||||||
|
- User satisfaction score (>4.5/5)
|
||||||
|
- Feature adoption rate (>80%)
|
||||||
|
|
||||||
|
### System Health Metrics
|
||||||
|
- System uptime (>99.9%)
|
||||||
|
- Data integrity (100%)
|
||||||
|
- Backup success rate (100%)
|
||||||
|
- Recovery time (<5min)
|
||||||
|
|
||||||
|
## Monitoring & Maintenance
|
||||||
|
|
||||||
|
### System Monitoring
|
||||||
|
- Real-time performance tracking
|
||||||
|
- Error rate monitoring
|
||||||
|
- Resource usage tracking
|
||||||
|
- User activity monitoring
|
||||||
|
|
||||||
|
### Maintenance Tasks
|
||||||
|
- Regular consistency checks
|
||||||
|
- Automated testing
|
||||||
|
- Performance optimization
|
||||||
|
- Security updates
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Access Control
|
||||||
|
- Role-based permissions
|
||||||
|
- Audit logging
|
||||||
|
- Activity monitoring
|
||||||
|
- Security scanning
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
- Encryption at rest
|
||||||
|
- Secure transmission
|
||||||
|
- Regular backups
|
||||||
|
- Data retention policies
|
||||||
114
memory-bank/features/version-control/implementation-status.md
Normal file
114
memory-bank/features/version-control/implementation-status.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Version Control System Implementation Status
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The version control system has been successfully implemented according to the implementation plan and technical guide. The system provides a robust version control solution integrated with django-simple-history and enhanced with branching, merging, and real-time collaboration capabilities.
|
||||||
|
|
||||||
|
## Implemented Components
|
||||||
|
|
||||||
|
### 1. Core Models
|
||||||
|
```python
|
||||||
|
# Core version control models in history_tracking/models.py
|
||||||
|
- VersionBranch: Manages parallel development branches
|
||||||
|
- VersionTag: Handles version tagging and releases
|
||||||
|
- ChangeSet: Tracks atomic groups of changes
|
||||||
|
- Integration with HistoricalModel and HistoricalChangeMixin
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Business Logic Layer
|
||||||
|
```python
|
||||||
|
# Managers and utilities in history_tracking/managers.py and utils.py
|
||||||
|
- BranchManager: Branch operations and management
|
||||||
|
- ChangeTracker: Change tracking and history
|
||||||
|
- MergeStrategy: Merge operations and conflict handling
|
||||||
|
- Utilities for conflict resolution and diff computation
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Frontend Integration
|
||||||
|
```html
|
||||||
|
# HTMX-based components in history_tracking/templates/
|
||||||
|
- Version Control Panel (version_control_panel.html)
|
||||||
|
- Branch Management (branch_list.html, branch_create.html)
|
||||||
|
- Change History Viewer (history_view.html)
|
||||||
|
- Merge Interface (merge_panel.html, merge_conflicts.html)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. API Layer
|
||||||
|
```python
|
||||||
|
# Views and endpoints in history_tracking/views.py
|
||||||
|
- VersionControlPanel: Main VCS interface
|
||||||
|
- BranchListView: Branch management
|
||||||
|
- HistoryView: Change history display
|
||||||
|
- MergeView: Merge operations
|
||||||
|
- BranchCreateView: Branch creation
|
||||||
|
- TagCreateView: Version tagging
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Signal Handlers
|
||||||
|
```python
|
||||||
|
# Signal handlers in history_tracking/signals.py
|
||||||
|
- Automatic change tracking
|
||||||
|
- Changeset management
|
||||||
|
- Branch context management
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema Changes
|
||||||
|
- Created models for branches, tags, and changesets
|
||||||
|
- Added proper indexes for performance
|
||||||
|
- Implemented GenericForeignKey relationships for flexibility
|
||||||
|
- Migrations created and applied successfully
|
||||||
|
|
||||||
|
## URL Configuration
|
||||||
|
```python
|
||||||
|
# Added to thrillwiki/urls.py
|
||||||
|
path("vcs/", include("history_tracking.urls", namespace="history"))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
1. django-simple-history integration
|
||||||
|
2. HTMX for real-time updates
|
||||||
|
3. Generic relations for flexibility
|
||||||
|
4. Signal handlers for automatic tracking
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
- [x] Branch creation and management
|
||||||
|
- [x] Version tagging system
|
||||||
|
- [x] Change tracking and history
|
||||||
|
- [x] Merge operations with conflict resolution
|
||||||
|
- [x] Real-time UI updates via HTMX
|
||||||
|
- [x] Generic content type support
|
||||||
|
- [x] Atomic change grouping
|
||||||
|
- [x] Branch relationship management
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
1. Add comprehensive test suite
|
||||||
|
2. Implement performance monitoring
|
||||||
|
3. Add user documentation
|
||||||
|
4. Consider adding advanced features like:
|
||||||
|
- Branch locking
|
||||||
|
- Advanced merge strategies
|
||||||
|
- Custom diff viewers
|
||||||
|
|
||||||
|
## Technical Documentation
|
||||||
|
- Implementation plan: [implementation-plan.md](implementation-plan.md)
|
||||||
|
- Technical guide: [technical-guide.md](technical-guide.md)
|
||||||
|
- API documentation: To be created
|
||||||
|
- User guide: To be created
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
- Indexed key fields for efficient querying
|
||||||
|
- Optimized database schema
|
||||||
|
- Efficient change tracking
|
||||||
|
- Real-time updates without full page reloads
|
||||||
|
|
||||||
|
## Security Measures
|
||||||
|
- Login required for all VCS operations
|
||||||
|
- Proper validation of all inputs
|
||||||
|
- CSRF protection
|
||||||
|
- Access control on branch operations
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
Future monitoring needs:
|
||||||
|
- Branch operation metrics
|
||||||
|
- Merge success rates
|
||||||
|
- Conflict frequency
|
||||||
|
- System performance metrics
|
||||||
325
memory-bank/features/version-control/technical-guide.md
Normal file
325
memory-bank/features/version-control/technical-guide.md
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
# Version Control System Technical Implementation Guide
|
||||||
|
|
||||||
|
## System Overview
|
||||||
|
The version control system implements full VCS capabilities with branching, merging, and collaboration features, building upon django-simple-history while adding robust versioning capabilities across the full stack.
|
||||||
|
|
||||||
|
## Core VCS Features
|
||||||
|
|
||||||
|
### 1. Branching System
|
||||||
|
|
||||||
|
```python
|
||||||
|
from vcs.models import VersionBranch, VersionTag, ChangeSet
|
||||||
|
|
||||||
|
class BranchManager:
|
||||||
|
def create_branch(name: str, parent: Optional[VersionBranch] = None):
|
||||||
|
"""Create a new branch"""
|
||||||
|
return VersionBranch.objects.create(
|
||||||
|
name=name,
|
||||||
|
parent=parent,
|
||||||
|
metadata={'created_by': current_user}
|
||||||
|
)
|
||||||
|
|
||||||
|
def merge_branches(source: VersionBranch, target: VersionBranch):
|
||||||
|
"""Merge two branches with conflict resolution"""
|
||||||
|
merger = MergeStrategy()
|
||||||
|
return merger.merge(source, target)
|
||||||
|
|
||||||
|
def list_branches():
|
||||||
|
"""Get all branches with their relationships"""
|
||||||
|
return VersionBranch.objects.select_related('parent').all()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Change Tracking
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ChangeTracker:
|
||||||
|
def record_change(model_instance, change_type, metadata=None):
|
||||||
|
"""Record a change in the system"""
|
||||||
|
return ChangeSet.objects.create(
|
||||||
|
instance=model_instance,
|
||||||
|
change_type=change_type,
|
||||||
|
metadata=metadata or {},
|
||||||
|
branch=get_current_branch()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_changes(branch: VersionBranch):
|
||||||
|
"""Get all changes in a branch"""
|
||||||
|
return ChangeSet.objects.filter(branch=branch).order_by('created_at')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Frontend Integration
|
||||||
|
|
||||||
|
#### State Management (React/TypeScript)
|
||||||
|
```typescript
|
||||||
|
interface VCSState {
|
||||||
|
currentBranch: Branch;
|
||||||
|
branches: Branch[];
|
||||||
|
changes: Change[];
|
||||||
|
conflicts: Conflict[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class VCSStore {
|
||||||
|
private state: VCSState;
|
||||||
|
|
||||||
|
async switchBranch(branchName: string): Promise<void> {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBranch(name: string): Promise<void> {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
async mergeBranch(source: string, target: string): Promise<void> {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UI Components
|
||||||
|
```typescript
|
||||||
|
// Branch Selector Component
|
||||||
|
const BranchSelector: React.FC = () => {
|
||||||
|
const branches = useVCSStore(state => state.branches);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="branch-selector">
|
||||||
|
{branches.map(branch => (
|
||||||
|
<BranchItem key={branch.id} branch={branch} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Change History Component
|
||||||
|
const ChangeHistory: React.FC = () => {
|
||||||
|
const changes = useVCSStore(state => state.changes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="change-history">
|
||||||
|
{changes.map(change => (
|
||||||
|
<ChangeItem key={change.id} change={change} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. API Integration
|
||||||
|
|
||||||
|
#### Django REST Framework ViewSets
|
||||||
|
```python
|
||||||
|
class VCSViewSet(viewsets.ModelViewSet):
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def create_branch(self, request):
|
||||||
|
name = request.data.get('name')
|
||||||
|
parent = request.data.get('parent')
|
||||||
|
|
||||||
|
branch = BranchManager().create_branch(name, parent)
|
||||||
|
return Response(BranchSerializer(branch).data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def merge(self, request):
|
||||||
|
source = request.data.get('source')
|
||||||
|
target = request.data.get('target')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = BranchManager().merge_branches(source, target)
|
||||||
|
return Response(result)
|
||||||
|
except MergeConflict as e:
|
||||||
|
return Response({'conflicts': e.conflicts}, status=409)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Conflict Resolution
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ConflictResolver:
|
||||||
|
def detect_conflicts(source: ChangeSet, target: ChangeSet) -> List[Conflict]:
|
||||||
|
"""Detect conflicts between changes"""
|
||||||
|
conflicts = []
|
||||||
|
# Implementation
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
def resolve_conflict(conflict: Conflict, resolution: Resolution):
|
||||||
|
"""Apply conflict resolution"""
|
||||||
|
with transaction.atomic():
|
||||||
|
# Implementation
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Real-time Collaboration
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CollaborationConsumer(AsyncWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
await self.channel_layer.group_add(
|
||||||
|
f"branch_{self.branch_id}",
|
||||||
|
self.channel_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def receive_change(self, event):
|
||||||
|
"""Handle incoming changes"""
|
||||||
|
change = event['change']
|
||||||
|
await self.process_change(change)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Branch Management
|
||||||
|
- Create feature branches for isolated development
|
||||||
|
- Use meaningful branch names
|
||||||
|
- Clean up merged branches
|
||||||
|
- Regular synchronization with main branch
|
||||||
|
|
||||||
|
### 2. Change Management
|
||||||
|
- Atomic changes
|
||||||
|
- Clear change descriptions
|
||||||
|
- Related changes grouped in changesets
|
||||||
|
- Regular commits
|
||||||
|
|
||||||
|
### 3. Conflict Resolution
|
||||||
|
- Early conflict detection
|
||||||
|
- Clear conflict documentation
|
||||||
|
- Structured resolution process
|
||||||
|
- Team communication
|
||||||
|
|
||||||
|
### 4. Performance Optimization
|
||||||
|
- Efficient change tracking
|
||||||
|
- Optimized queries
|
||||||
|
- Caching strategy
|
||||||
|
- Background processing
|
||||||
|
|
||||||
|
### 5. Security
|
||||||
|
- Access control
|
||||||
|
- Audit logging
|
||||||
|
- Data validation
|
||||||
|
- Secure transmission
|
||||||
|
|
||||||
|
## Implementation Examples
|
||||||
|
|
||||||
|
### 1. Creating a New Branch
|
||||||
|
```python
|
||||||
|
branch_manager = BranchManager()
|
||||||
|
feature_branch = branch_manager.create_branch(
|
||||||
|
name="feature/new-ui",
|
||||||
|
parent=main_branch
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Recording Changes
|
||||||
|
```python
|
||||||
|
change_tracker = ChangeTracker()
|
||||||
|
change = change_tracker.record_change(
|
||||||
|
instance=model_object,
|
||||||
|
change_type="update",
|
||||||
|
metadata={"field": "title", "reason": "Improvement"}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Merging Branches
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
result = branch_manager.merge_branches(
|
||||||
|
source=feature_branch,
|
||||||
|
target=main_branch
|
||||||
|
)
|
||||||
|
except MergeConflict as e:
|
||||||
|
conflicts = e.conflicts
|
||||||
|
resolution = conflict_resolver.resolve_conflicts(conflicts)
|
||||||
|
result = branch_manager.apply_resolution(resolution)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### 1. Branch Operations
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
branch = branch_manager.create_branch(name)
|
||||||
|
except BranchExistsError:
|
||||||
|
# Handle duplicate branch
|
||||||
|
except InvalidBranchNameError:
|
||||||
|
# Handle invalid name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Merge Operations
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
result = branch_manager.merge_branches(source, target)
|
||||||
|
except MergeConflictError as e:
|
||||||
|
# Handle merge conflicts
|
||||||
|
except InvalidBranchError:
|
||||||
|
# Handle invalid branch
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### 1. Performance Monitoring
|
||||||
|
```python
|
||||||
|
class VCSMonitor:
|
||||||
|
def track_operation(operation_type, duration):
|
||||||
|
"""Track operation performance"""
|
||||||
|
|
||||||
|
def check_system_health():
|
||||||
|
"""Verify system health"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Error Tracking
|
||||||
|
```python
|
||||||
|
class ErrorTracker:
|
||||||
|
def log_error(error_type, details):
|
||||||
|
"""Log system errors"""
|
||||||
|
|
||||||
|
def analyze_errors():
|
||||||
|
"""Analyze error patterns"""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### 1. Unit Tests
|
||||||
|
```python
|
||||||
|
class BranchTests(TestCase):
|
||||||
|
def test_branch_creation(self):
|
||||||
|
"""Test branch creation"""
|
||||||
|
|
||||||
|
def test_branch_merge(self):
|
||||||
|
"""Test branch merging"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Integration Tests
|
||||||
|
```python
|
||||||
|
class VCSIntegrationTests(TestCase):
|
||||||
|
def test_complete_workflow(self):
|
||||||
|
"""Test complete VCS workflow"""
|
||||||
|
|
||||||
|
def test_conflict_resolution(self):
|
||||||
|
"""Test conflict resolution"""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Considerations
|
||||||
|
|
||||||
|
### 1. Database Migrations
|
||||||
|
- Create necessary tables
|
||||||
|
- Add indexes
|
||||||
|
- Handle existing data
|
||||||
|
|
||||||
|
### 2. Cache Setup
|
||||||
|
- Configure Redis
|
||||||
|
- Set up caching strategy
|
||||||
|
- Implement cache invalidation
|
||||||
|
|
||||||
|
### 3. Background Tasks
|
||||||
|
- Configure Celery
|
||||||
|
- Set up task queues
|
||||||
|
- Monitor task execution
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### 1. Regular Tasks
|
||||||
|
- Clean up old branches
|
||||||
|
- Optimize database
|
||||||
|
- Update indexes
|
||||||
|
- Verify backups
|
||||||
|
|
||||||
|
### 2. Monitoring Tasks
|
||||||
|
- Check system health
|
||||||
|
- Monitor performance
|
||||||
|
- Track error rates
|
||||||
|
- Analyze usage patterns
|
||||||
223
static/js/moderation.js
Normal file
223
static/js/moderation.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
// Validation Helpers
|
||||||
|
const ValidationRules = {
|
||||||
|
date: {
|
||||||
|
validate: (value, input) => {
|
||||||
|
if (!value) return true;
|
||||||
|
const date = new Date(value);
|
||||||
|
const now = new Date();
|
||||||
|
const min = new Date('1800-01-01');
|
||||||
|
|
||||||
|
if (date > now) {
|
||||||
|
return 'Date cannot be in the future';
|
||||||
|
}
|
||||||
|
if (date < min) {
|
||||||
|
return 'Date cannot be before 1800';
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
numeric: {
|
||||||
|
validate: (value, input) => {
|
||||||
|
if (!value) return true;
|
||||||
|
const num = parseFloat(value);
|
||||||
|
const min = parseFloat(input.getAttribute('min') || '-Infinity');
|
||||||
|
const max = parseFloat(input.getAttribute('max') || 'Infinity');
|
||||||
|
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return 'Please enter a valid number';
|
||||||
|
}
|
||||||
|
if (num < min) {
|
||||||
|
return `Value must be at least ${min}`;
|
||||||
|
}
|
||||||
|
if (num > max) {
|
||||||
|
return `Value must be no more than ${max}`;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Form Validation and Error Handling
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Form Validation
|
||||||
|
document.querySelectorAll('form[hx-post]').forEach(form => {
|
||||||
|
// Add validation on field change
|
||||||
|
form.addEventListener('input', function(e) {
|
||||||
|
const input = e.target;
|
||||||
|
if (input.hasAttribute('data-validate')) {
|
||||||
|
validateField(input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('htmx:beforeRequest', function(event) {
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
// Validate all fields
|
||||||
|
form.querySelectorAll('[data-validate]').forEach(input => {
|
||||||
|
if (!validateField(input)) {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check required notes field
|
||||||
|
const notesField = form.querySelector('textarea[name="notes"]');
|
||||||
|
if (notesField && !notesField.value.trim()) {
|
||||||
|
showError(notesField, 'Notes are required');
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
event.preventDefault();
|
||||||
|
// Focus first invalid field
|
||||||
|
form.querySelector('.border-red-500')?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear error states on input
|
||||||
|
form.addEventListener('input', function(e) {
|
||||||
|
if (e.target.classList.contains('border-red-500')) {
|
||||||
|
e.target.classList.remove('border-red-500');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form State Management
|
||||||
|
document.querySelectorAll('form[hx-post]').forEach(form => {
|
||||||
|
const formId = form.getAttribute('id');
|
||||||
|
if (!formId) return;
|
||||||
|
|
||||||
|
// Save form state before submission
|
||||||
|
form.addEventListener('htmx:beforeRequest', function() {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const state = {};
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
state[key] = value;
|
||||||
|
});
|
||||||
|
sessionStorage.setItem('formState-' + formId, JSON.stringify(state));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore form state if available
|
||||||
|
const savedState = sessionStorage.getItem('formState-' + formId);
|
||||||
|
if (savedState) {
|
||||||
|
const state = JSON.parse(savedState);
|
||||||
|
Object.entries(state).forEach(([key, value]) => {
|
||||||
|
const input = form.querySelector(`[name="${key}"]`);
|
||||||
|
if (input) {
|
||||||
|
input.value = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Park Area Sync with Park Selection
|
||||||
|
document.querySelectorAll('[id^="park-input-"]').forEach(parkInput => {
|
||||||
|
const submissionId = parkInput.id.replace('park-input-', '');
|
||||||
|
const areaSelect = document.querySelector(`#park-area-select-${submissionId}`);
|
||||||
|
|
||||||
|
if (parkInput && areaSelect) {
|
||||||
|
parkInput.addEventListener('change', function() {
|
||||||
|
const parkId = this.value;
|
||||||
|
if (!parkId) {
|
||||||
|
areaSelect.innerHTML = '<option value="">Select area</option>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
htmx.ajax('GET', `/parks/${parkId}/areas/`, {
|
||||||
|
target: areaSelect,
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Improved Error Handling
|
||||||
|
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||||
|
const errorToast = document.createElement('div');
|
||||||
|
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 flex items-center';
|
||||||
|
errorToast.innerHTML = `
|
||||||
|
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||||
|
<span>${evt.detail.error || 'An error occurred'}</span>
|
||||||
|
<button class="ml-4 hover:text-red-200" onclick="this.parentElement.remove()">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(errorToast);
|
||||||
|
setTimeout(() => {
|
||||||
|
errorToast.remove();
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accessibility Improvements
|
||||||
|
document.addEventListener('htmx:afterSettle', function(evt) {
|
||||||
|
// Focus management
|
||||||
|
const target = evt.detail.target;
|
||||||
|
const focusableElement = target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||||
|
if (focusableElement) {
|
||||||
|
focusableElement.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announce state changes
|
||||||
|
if (target.hasAttribute('aria-live')) {
|
||||||
|
const announcement = target.getAttribute('aria-label') || target.textContent;
|
||||||
|
const announcer = document.getElementById('a11y-announcer') || createAnnouncer();
|
||||||
|
announcer.textContent = announcement;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to create accessibility announcer
|
||||||
|
function createAnnouncer() {
|
||||||
|
const announcer = document.createElement('div');
|
||||||
|
announcer.id = 'a11y-announcer';
|
||||||
|
announcer.className = 'sr-only';
|
||||||
|
announcer.setAttribute('aria-live', 'polite');
|
||||||
|
document.body.appendChild(announcer);
|
||||||
|
return announcer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation Helper Functions
|
||||||
|
function validateField(input) {
|
||||||
|
const validationType = input.getAttribute('data-validate');
|
||||||
|
if (!validationType || !ValidationRules[validationType]) return true;
|
||||||
|
|
||||||
|
const result = ValidationRules[validationType].validate(input.value, input);
|
||||||
|
if (result === true) {
|
||||||
|
clearError(input);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
showError(input, result);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(input, message) {
|
||||||
|
const errorId = input.getAttribute('aria-describedby');
|
||||||
|
const errorElement = document.getElementById(errorId);
|
||||||
|
|
||||||
|
input.classList.add('border-red-500', 'error-shake');
|
||||||
|
if (errorElement) {
|
||||||
|
errorElement.textContent = message;
|
||||||
|
errorElement.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announce error to screen readers
|
||||||
|
const announcer = document.getElementById('a11y-announcer');
|
||||||
|
if (announcer) {
|
||||||
|
announcer.textContent = `Error: ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
input.classList.remove('error-shake');
|
||||||
|
}, 820);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError(input) {
|
||||||
|
const errorId = input.getAttribute('aria-describedby');
|
||||||
|
const errorElement = document.getElementById(errorId);
|
||||||
|
|
||||||
|
input.classList.remove('border-red-500');
|
||||||
|
if (errorElement) {
|
||||||
|
errorElement.classList.add('hidden');
|
||||||
|
errorElement.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,152 +146,196 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container max-w-6xl px-4 py-6 mx-auto">
|
<div class="container max-w-6xl px-4 py-6 mx-auto">
|
||||||
<div id="dashboard-content" class="relative transition-all duration-200">
|
<div id="dashboard-content"
|
||||||
|
class="relative transition-all duration-200"
|
||||||
|
hx-target="this"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-indicator="#loading-skeleton"
|
||||||
|
hx-swap="outerHTML">
|
||||||
{% block moderation_content %}
|
{% block moderation_content %}
|
||||||
{% include "moderation/partials/dashboard_content.html" %}
|
{% include "moderation/partials/dashboard_content.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
<!-- Loading Skeleton -->
|
<!-- Loading Skeleton -->
|
||||||
<div class="absolute inset-0 htmx-indicator" id="loading-skeleton">
|
<div class="absolute inset-0 htmx-indicator opacity-0"
|
||||||
|
id="loading-skeleton"
|
||||||
|
aria-hidden="true">
|
||||||
{% include "moderation/partials/loading_skeleton.html" %}
|
{% include "moderation/partials/loading_skeleton.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div class="absolute inset-0 hidden" id="error-state">
|
<div class="absolute inset-0 hidden"
|
||||||
<div class="flex flex-col items-center justify-center h-full p-6 space-y-4 text-center">
|
id="error-state"
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive">
|
||||||
|
<div class="flex flex-col items-center justify-center h-full p-6 space-y-4 text-center"
|
||||||
|
x-data="{ errorMessage: 'There was a problem loading the content. Please try again.' }"
|
||||||
|
x-init="$watch('errorMessage', value => $dispatch('show-toast', { message: value, type: 'error' }))">
|
||||||
<div class="p-4 text-red-500 bg-red-100 rounded-full dark:bg-red-900/40">
|
<div class="p-4 text-red-500 bg-red-100 rounded-full dark:bg-red-900/40">
|
||||||
<i class="text-4xl fas fa-exclamation-circle"></i>
|
<i class="text-4xl fas fa-exclamation-circle" aria-hidden="true"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-lg font-medium text-red-600 dark:text-red-400">
|
<h3 class="text-lg font-medium text-red-600 dark:text-red-400">
|
||||||
Something went wrong
|
Something went wrong
|
||||||
</h3>
|
</h3>
|
||||||
<p class="max-w-md text-gray-600 dark:text-gray-400" id="error-message">
|
<p class="max-w-md text-gray-600 dark:text-gray-400"
|
||||||
There was a problem loading the content. Please try again.
|
id="error-message"
|
||||||
</p>
|
x-text="errorMessage"></p>
|
||||||
<button class="px-4 py-2 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600"
|
<button class="px-4 py-2 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
onclick="window.location.reload()">
|
hx-get="{{ request.path }}"
|
||||||
<i class="mr-2 fas fa-sync-alt"></i>
|
hx-target="#dashboard-content"
|
||||||
|
hx-push-url="true"
|
||||||
|
hx-indicator="this"
|
||||||
|
@click="$el.disabled = true"
|
||||||
|
hx-on::after-request="$el.disabled = false">
|
||||||
|
<span class="htmx-indicator">
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||||||
|
Retrying...
|
||||||
|
</span>
|
||||||
|
<span class="htmx-settled">
|
||||||
|
<i class="mr-2 fas fa-sync-alt" aria-hidden="true"></i>
|
||||||
Retry
|
Retry
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast Notifications -->
|
||||||
|
<div id="toast-container"
|
||||||
|
class="fixed bottom-4 right-4 z-50 space-y-2"
|
||||||
|
x-data="{
|
||||||
|
toasts: [],
|
||||||
|
add(message, type = 'success') {
|
||||||
|
const id = Date.now();
|
||||||
|
this.toasts.push({ id, message, type });
|
||||||
|
setTimeout(() => this.remove(id), 5000);
|
||||||
|
},
|
||||||
|
remove(id) {
|
||||||
|
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@show-toast.window="add($event.detail.message, $event.detail.type)"
|
||||||
|
@htmx:responseError.window="add($event.detail.error || 'An error occurred', 'error')"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true">
|
||||||
|
<template x-for="toast in toasts" :key="toast.id">
|
||||||
|
<div class="flex items-center p-4 rounded-lg shadow-lg transform transition-all duration-300"
|
||||||
|
:class="{
|
||||||
|
'bg-green-600': toast.type === 'success',
|
||||||
|
'bg-red-600': toast.type === 'error',
|
||||||
|
'bg-yellow-600': toast.type === 'warning',
|
||||||
|
'bg-blue-600': toast.type === 'info'
|
||||||
|
}"
|
||||||
|
x-transition:enter="ease-out"
|
||||||
|
x-transition:enter-start="opacity-0 translate-y-2"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave="ease-in"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0">
|
||||||
|
<div class="flex-1 text-white">
|
||||||
|
<p class="font-medium" x-text="toast.message"></p>
|
||||||
|
</div>
|
||||||
|
<button @click="remove(toast.id)"
|
||||||
|
class="ml-4 text-white hover:text-white/80"
|
||||||
|
aria-label="Close notification">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
<!-- HTMX Event Handlers -->
|
||||||
<script>
|
<script>
|
||||||
// HTMX Configuration and Enhancements
|
|
||||||
document.body.addEventListener('htmx:configRequest', function(evt) {
|
|
||||||
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Loading and Error State Management
|
|
||||||
const dashboard = {
|
|
||||||
content: document.getElementById('dashboard-content'),
|
|
||||||
skeleton: document.getElementById('loading-skeleton'),
|
|
||||||
errorState: document.getElementById('error-state'),
|
|
||||||
errorMessage: document.getElementById('error-message'),
|
|
||||||
|
|
||||||
showLoading() {
|
|
||||||
this.content.setAttribute('aria-busy', 'true');
|
|
||||||
this.content.style.opacity = '0';
|
|
||||||
this.errorState.classList.add('hidden');
|
|
||||||
},
|
|
||||||
|
|
||||||
hideLoading() {
|
|
||||||
this.content.setAttribute('aria-busy', 'false');
|
|
||||||
this.content.style.opacity = '1';
|
|
||||||
},
|
|
||||||
|
|
||||||
showError(message) {
|
|
||||||
this.errorState.classList.remove('hidden');
|
|
||||||
this.errorMessage.textContent = message || 'There was a problem loading the content. Please try again.';
|
|
||||||
// Announce error to screen readers
|
|
||||||
this.errorMessage.setAttribute('role', 'alert');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Enhanced HTMX Event Handlers
|
|
||||||
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
||||||
if (evt.detail.target.id === 'dashboard-content') {
|
const target = evt.detail.target;
|
||||||
dashboard.showLoading();
|
if (target.hasAttribute('hx-disabled-elt')) {
|
||||||
|
const disabledElt = document.querySelector(target.getAttribute('hx-disabled-elt'));
|
||||||
|
if (disabledElt) {
|
||||||
|
disabledElt.disabled = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
|
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||||
if (evt.detail.target.id === 'dashboard-content') {
|
const target = evt.detail.target;
|
||||||
dashboard.hideLoading();
|
if (target.hasAttribute('hx-disabled-elt')) {
|
||||||
// Reset focus for accessibility
|
const disabledElt = document.querySelector(target.getAttribute('hx-disabled-elt'));
|
||||||
const firstFocusable = evt.detail.target.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
if (disabledElt) {
|
||||||
if (firstFocusable) {
|
disabledElt.disabled = false;
|
||||||
firstFocusable.focus();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||||
if (evt.detail.target.id === 'dashboard-content') {
|
const errorToast = new CustomEvent('show-toast', {
|
||||||
dashboard.showError(evt.detail.error);
|
detail: {
|
||||||
|
message: evt.detail.error || 'An error occurred while processing your request',
|
||||||
|
type: 'error'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
window.dispatchEvent(errorToast);
|
||||||
// Search Input Debouncing
|
|
||||||
function debounce(func, wait) {
|
|
||||||
let timeout;
|
|
||||||
return function executedFunction(...args) {
|
|
||||||
const later = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
func(...args);
|
|
||||||
};
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(later, wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply debouncing to search inputs
|
|
||||||
document.querySelectorAll('[data-search]').forEach(input => {
|
|
||||||
const originalSearch = () => {
|
|
||||||
htmx.trigger(input, 'input');
|
|
||||||
};
|
|
||||||
const debouncedSearch = debounce(originalSearch, 300);
|
|
||||||
|
|
||||||
input.addEventListener('input', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
debouncedSearch();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Virtual Scrolling for Large Lists
|
document.body.addEventListener('htmx:sendError', function(evt) {
|
||||||
const observerOptions = {
|
const errorToast = new CustomEvent('show-toast', {
|
||||||
root: null,
|
detail: {
|
||||||
rootMargin: '100px',
|
message: 'Network error: Could not connect to the server',
|
||||||
threshold: 0.1
|
type: 'error'
|
||||||
};
|
|
||||||
|
|
||||||
const loadMoreContent = (entries, observer) => {
|
|
||||||
entries.forEach(entry => {
|
|
||||||
if (entry.isIntersecting && !entry.target.classList.contains('loading')) {
|
|
||||||
entry.target.classList.add('loading');
|
|
||||||
htmx.trigger(entry.target, 'intersect');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
window.dispatchEvent(errorToast);
|
||||||
|
|
||||||
const observer = new IntersectionObserver(loadMoreContent, observerOptions);
|
|
||||||
document.querySelectorAll('[data-infinite-scroll]').forEach(el => observer.observe(el));
|
|
||||||
|
|
||||||
// Keyboard Navigation Enhancement
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
const openModals = document.querySelectorAll('[x-show="showNotes"]');
|
|
||||||
openModals.forEach(modal => {
|
|
||||||
const alpineData = modal.__x.$data;
|
|
||||||
if (alpineData.showNotes) {
|
|
||||||
alpineData.showNotes = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<!-- Base HTMX Configuration -->
|
||||||
|
<script>
|
||||||
|
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||||
|
evt.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Custom Moderation JS -->
|
||||||
|
<script src="{% static 'js/moderation.js' %}"></script>
|
||||||
|
|
||||||
|
<!-- Enhanced Mobile Styles -->
|
||||||
|
<style>
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.action-buttons {
|
||||||
|
@apply flex-col w-full space-y-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons > button {
|
||||||
|
@apply w-full justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
@apply fixed bottom-0 left-0 right-0 max-h-[50vh] overflow-y-auto bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 rounded-t-xl shadow-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
@apply grid-cols-1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Touch Device Optimizations */
|
||||||
|
@media (hover: none) {
|
||||||
|
.touch-target {
|
||||||
|
@apply min-h-[44px] min-w-[44px] p-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.touch-friendly-select {
|
||||||
|
@apply py-2.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- Accessibility Improvements -->
|
||||||
|
<div id="a11y-announcer"
|
||||||
|
class="sr-only"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true">
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,129 +1,69 @@
|
|||||||
{% comment %}
|
{% load static %}
|
||||||
This template contains the Alpine.js store for managing filter state in the moderation dashboard
|
|
||||||
{% endcomment %}
|
|
||||||
|
|
||||||
<script>
|
<div x-data
|
||||||
document.addEventListener('alpine:init', () => {
|
x-init="$store.filters = {
|
||||||
Alpine.store('filters', {
|
|
||||||
active: [],
|
active: [],
|
||||||
|
labels: {
|
||||||
|
'submission_type': 'Type',
|
||||||
|
'content_type': 'Content',
|
||||||
|
'type': 'Change Type',
|
||||||
|
'status': 'Status'
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
'submission_type': {
|
||||||
|
'text': 'Text',
|
||||||
|
'photo': 'Photo'
|
||||||
|
},
|
||||||
|
'content_type': {
|
||||||
|
'park': 'Park',
|
||||||
|
'ride': 'Ride',
|
||||||
|
'company': 'Company'
|
||||||
|
},
|
||||||
|
'type': {
|
||||||
|
'CREATE': 'New',
|
||||||
|
'EDIT': 'Edit'
|
||||||
|
},
|
||||||
|
'status': {
|
||||||
|
'PENDING': 'Pending',
|
||||||
|
'APPROVED': 'Approved',
|
||||||
|
'REJECTED': 'Rejected',
|
||||||
|
'ESCALATED': 'Escalated'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hasActiveFilters: false,
|
||||||
|
updateActiveFilters() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
this.active = [];
|
||||||
|
|
||||||
init() {
|
params.forEach((value, key) => {
|
||||||
this.updateActiveFilters();
|
if (value && this.labels[key]) {
|
||||||
|
this.active.push({
|
||||||
|
name: key,
|
||||||
|
label: this.labels[key],
|
||||||
|
value: this.values[key][value] || value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.hasActiveFilters = this.active.length > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize filters from URL
|
||||||
|
$store.filters.updateActiveFilters();
|
||||||
|
|
||||||
// Listen for filter changes
|
// Listen for filter changes
|
||||||
window.addEventListener('filter-changed', () => {
|
window.addEventListener('filter-changed', () => {
|
||||||
this.updateActiveFilters();
|
$store.filters.updateActiveFilters();
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
updateActiveFilters() {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
this.active = [];
|
|
||||||
|
|
||||||
// Submission Type
|
|
||||||
if (urlParams.has('submission_type')) {
|
|
||||||
this.active.push({
|
|
||||||
name: 'submission_type',
|
|
||||||
label: 'Submission',
|
|
||||||
value: this.getSubmissionTypeLabel(urlParams.get('submission_type'))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type
|
|
||||||
if (urlParams.has('type')) {
|
|
||||||
this.active.push({
|
|
||||||
name: 'type',
|
|
||||||
label: 'Type',
|
|
||||||
value: this.getTypeLabel(urlParams.get('type'))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content Type
|
|
||||||
if (urlParams.has('content_type')) {
|
|
||||||
this.active.push({
|
|
||||||
name: 'content_type',
|
|
||||||
label: 'Content',
|
|
||||||
value: this.getContentTypeLabel(urlParams.get('content_type'))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getSubmissionTypeLabel(value) {
|
|
||||||
const labels = {
|
|
||||||
'text': 'Text',
|
|
||||||
'photo': 'Photo'
|
|
||||||
};
|
|
||||||
return labels[value] || value;
|
|
||||||
},
|
|
||||||
|
|
||||||
getTypeLabel(value) {
|
|
||||||
const labels = {
|
|
||||||
'CREATE': 'New',
|
|
||||||
'EDIT': 'Edit'
|
|
||||||
};
|
|
||||||
return labels[value] || value;
|
|
||||||
},
|
|
||||||
|
|
||||||
getContentTypeLabel(value) {
|
|
||||||
const labels = {
|
|
||||||
'park': 'Parks',
|
|
||||||
'ride': 'Rides',
|
|
||||||
'company': 'Companies'
|
|
||||||
};
|
|
||||||
return labels[value] || value;
|
|
||||||
},
|
|
||||||
|
|
||||||
get hasActiveFilters() {
|
|
||||||
return this.active.length > 0;
|
|
||||||
},
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
const form = document.querySelector('form[hx-get]');
|
|
||||||
if (form) {
|
|
||||||
form.querySelectorAll('select').forEach(select => {
|
|
||||||
select.value = '';
|
|
||||||
});
|
|
||||||
form.dispatchEvent(new Event('change'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Accessibility Helpers
|
|
||||||
announceFilterChange() {
|
|
||||||
const message = this.hasActiveFilters
|
|
||||||
? `Applied filters: ${this.active.map(f => f.label + ': ' + f.value).join(', ')}`
|
|
||||||
: 'All filters cleared';
|
|
||||||
|
|
||||||
const announcement = document.createElement('div');
|
|
||||||
announcement.setAttribute('role', 'status');
|
|
||||||
announcement.setAttribute('aria-live', 'polite');
|
|
||||||
announcement.className = 'sr-only';
|
|
||||||
announcement.textContent = message;
|
|
||||||
|
|
||||||
document.body.appendChild(announcement);
|
|
||||||
setTimeout(() => announcement.remove(), 1000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for filter changes and update URL params
|
// Listen for URL changes
|
||||||
document.addEventListener('filter-changed', (e) => {
|
window.addEventListener('htmx:historyRestore', () => {
|
||||||
const form = e.target.closest('form');
|
$store.filters.updateActiveFilters();
|
||||||
if (!form) return;
|
|
||||||
|
|
||||||
const formData = new FormData(form);
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
for (let [key, value] of formData.entries()) {
|
|
||||||
if (value) {
|
|
||||||
params.append(key, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update URL without page reload
|
|
||||||
const newUrl = window.location.pathname + (params.toString() ? '?' + params.toString() : '');
|
|
||||||
window.history.pushState({}, '', newUrl);
|
|
||||||
|
|
||||||
// Announce changes for screen readers
|
|
||||||
Alpine.store('filters').announceFilterChange();
|
|
||||||
});
|
});
|
||||||
</script>
|
|
||||||
|
// Listen for HTMX after swap
|
||||||
|
window.addEventListener('htmx:afterSwap', () => {
|
||||||
|
$store.filters.updateActiveFilters();
|
||||||
|
})">
|
||||||
|
</div>
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
<div class="animate-pulse">
|
<div class="space-y-6 animate-pulse">
|
||||||
<!-- Filter Bar Skeleton -->
|
<!-- Navigation Skeleton -->
|
||||||
<div class="flex items-center justify-between p-4 mb-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
<div class="flex items-center justify-between p-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
{% for i in "1234" %}
|
{% for i in '1234'|make_list %}
|
||||||
<div class="w-24 h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
<div class="w-24 h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24 h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
<div class="w-24 h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Form Skeleton -->
|
<!-- Filter Section Skeleton -->
|
||||||
<div class="p-6 mb-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||||
<div class="flex flex-wrap items-end gap-4">
|
<div class="mb-6">
|
||||||
{% for i in "123" %}
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div class="flex-1 min-w-[200px] space-y-2">
|
{% for i in '123'|make_list %}
|
||||||
|
<div class="space-y-2">
|
||||||
<div class="w-24 h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
|
<div class="w-24 h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||||
<div class="w-full h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
<div class="w-full h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -23,19 +24,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submission List Skeleton -->
|
<!-- Submissions Skeleton -->
|
||||||
{% for i in "123" %}
|
<div class="space-y-4">
|
||||||
<div class="p-6 mb-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
{% for i in '123'|make_list %}
|
||||||
|
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
<!-- Left Column -->
|
<!-- Left Column -->
|
||||||
<div class="space-y-4 md:col-span-1">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="w-32 h-6 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||||
<div class="w-24 h-6 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
<div class="space-y-2">
|
||||||
</div>
|
{% for j in '1234'|make_list %}
|
||||||
<div class="space-y-3">
|
<div class="flex items-center">
|
||||||
{% for i in "1234" %}
|
<div class="w-5 h-5 mr-2 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="w-5 h-5 bg-gray-200 rounded dark:bg-gray-700"></div>
|
|
||||||
<div class="w-32 h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
|
<div class="w-32 h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -43,24 +43,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2 space-y-4">
|
||||||
{% for i in "12" %}
|
<!-- Content Details -->
|
||||||
<div class="p-4 mb-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div class="w-24 h-4 mb-2 bg-gray-200 rounded dark:bg-gray-700"></div>
|
{% for j in '1234'|make_list %}
|
||||||
<div class="w-full h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 mt-4 md:grid-cols-2">
|
|
||||||
{% for i in "1234" %}
|
|
||||||
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
<div class="p-4 bg-gray-100 border rounded-lg dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50">
|
||||||
<div class="w-24 h-4 mb-2 bg-gray-200 rounded dark:bg-gray-700"></div>
|
<div class="w-24 h-4 mb-2 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||||
<div class="w-full h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
|
<div class="w-full h-4 bg-gray-200 rounded dark:bg-gray-700"></div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
{% for j in '1234'|make_list %}
|
||||||
|
<div class="w-24 h-10 bg-gray-200 rounded-lg dark:bg-gray-700"></div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% for submission in submissions %}
|
{% for submission in submissions %}
|
||||||
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50"
|
<div class="p-4 sm:p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50"
|
||||||
id="submission-{{ submission.id }}"
|
id="submission-{{ submission.id }}"
|
||||||
|
role="article"
|
||||||
|
aria-labelledby="submission-header-{{ submission.id }}"
|
||||||
x-data="{
|
x-data="{
|
||||||
showSuccess: false,
|
showSuccess: false,
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
@@ -173,8 +175,13 @@
|
|||||||
<!-- Edit Mode -->
|
<!-- Edit Mode -->
|
||||||
<form x-show="isEditing"
|
<form x-show="isEditing"
|
||||||
x-cloak
|
x-cloak
|
||||||
|
id="edit-form-{{ submission.id }}"
|
||||||
hx-post="{% url 'moderation:edit_submission' submission.id %}"
|
hx-post="{% url 'moderation:edit_submission' submission.id %}"
|
||||||
hx-target="#submission-{{ submission.id }}"
|
hx-target="#submission-{{ submission.id }}"
|
||||||
|
hx-indicator="#loading-indicator-{{ submission.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-on::before-request="document.getElementById('edit-form-{{ submission.id }}').classList.add('submitting')"
|
||||||
|
hx-on::after-request="document.getElementById('edit-form-{{ submission.id }}').classList.remove('submitting')"
|
||||||
class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
|
||||||
<!-- Location Widget for Parks -->
|
<!-- Location Widget for Parks -->
|
||||||
@@ -224,13 +231,23 @@
|
|||||||
<option value="CLOSED_PERM" {% if value == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
<option value="CLOSED_PERM" {% if value == 'CLOSED_PERM' %}selected{% endif %}>Permanently Closed</option>
|
||||||
</select>
|
</select>
|
||||||
{% elif field == 'opening_date' or field == 'closing_date' or field == 'status_since' %}
|
{% elif field == 'opening_date' or field == 'closing_date' or field == 'status_since' %}
|
||||||
|
<div class="relative">
|
||||||
<input type="date"
|
<input type="date"
|
||||||
|
id="{{ field }}-{{ submission.id }}"
|
||||||
name="{{ field }}"
|
name="{{ field }}"
|
||||||
value="{{ value|date:'Y-m-d' }}"
|
value="{{ value|date:'Y-m-d' }}"
|
||||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500 touch-friendly-select"
|
||||||
{% if field == 'closing_date' %}
|
{% if field == 'closing_date' %}
|
||||||
:required="status === 'CLOSING'"
|
:required="status === 'CLOSING'"
|
||||||
{% endif %}>
|
data-validate="date"
|
||||||
|
{% endif %}
|
||||||
|
aria-describedby="{{ field }}-error-{{ submission.id }}"
|
||||||
|
min="1800-01-01"
|
||||||
|
max="{{ now|date:'Y-m-d' }}">
|
||||||
|
<div id="{{ field }}-error-{{ submission.id }}"
|
||||||
|
class="hidden absolute -bottom-6 left-0 text-sm text-red-600 dark:text-red-400"
|
||||||
|
role="alert"></div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if field == 'park' %}
|
{% if field == 'park' %}
|
||||||
<div class="relative space-y-2">
|
<div class="relative space-y-2">
|
||||||
@@ -339,12 +356,24 @@
|
|||||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||||
placeholder="General description and notable features">{{ value }}</textarea>
|
placeholder="General description and notable features">{{ value }}</textarea>
|
||||||
{% elif field == 'min_height_in' or field == 'max_height_in' %}
|
{% elif field == 'min_height_in' or field == 'max_height_in' %}
|
||||||
|
<div class="relative">
|
||||||
<input type="number"
|
<input type="number"
|
||||||
|
id="{{ field }}-{{ submission.id }}"
|
||||||
name="{{ field }}"
|
name="{{ field }}"
|
||||||
value="{{ value }}"
|
value="{{ value }}"
|
||||||
min="0"
|
min="0"
|
||||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
step="0.1"
|
||||||
placeholder="Height in inches">
|
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500 touch-friendly-select"
|
||||||
|
placeholder="Height in inches"
|
||||||
|
aria-describedby="{{ field }}-error-{{ submission.id }}"
|
||||||
|
data-validate="numeric">
|
||||||
|
<div id="{{ field }}-error-{{ submission.id }}"
|
||||||
|
class="hidden absolute -bottom-6 left-0 text-sm text-red-600 dark:text-red-400"
|
||||||
|
role="alert"></div>
|
||||||
|
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">in</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% elif field == 'capacity_per_hour' %}
|
{% elif field == 'capacity_per_hour' %}
|
||||||
<input type="number"
|
<input type="number"
|
||||||
name="{{ field }}"
|
name="{{ field }}"
|
||||||
@@ -378,14 +407,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-2 p-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
|
<div class="col-span-2 p-4 border rounded-lg bg-blue-50 dark:bg-blue-900/30 border-blue-200/50 dark:border-blue-700/50">
|
||||||
<label class="block mb-2 text-sm font-medium text-blue-900 dark:text-blue-300">
|
<label for="notes-{{ submission.id }}"
|
||||||
|
class="block mb-2 text-sm font-medium text-blue-900 dark:text-blue-300">
|
||||||
Notes (required):
|
Notes (required):
|
||||||
</label>
|
</label>
|
||||||
<textarea name="notes"
|
<div class="relative">
|
||||||
|
<textarea id="notes-{{ submission.id }}"
|
||||||
|
name="notes"
|
||||||
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg resize-none dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
class="w-full px-3 py-2 text-gray-900 bg-white border rounded-lg resize-none dark:text-gray-300 dark:bg-gray-900 border-gray-200/50 dark:border-gray-700/50 focus:ring-2 focus:ring-blue-500"
|
||||||
rows="3"
|
rows="3"
|
||||||
required
|
required
|
||||||
|
aria-required="true"
|
||||||
|
aria-describedby="notes-error-{{ submission.id }}"
|
||||||
placeholder="Explain why you're editing this submission"></textarea>
|
placeholder="Explain why you're editing this submission"></textarea>
|
||||||
|
<div id="notes-error-{{ submission.id }}"
|
||||||
|
class="hidden absolute -bottom-6 left-0 text-sm text-red-600 dark:text-red-400"
|
||||||
|
role="alert"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end col-span-2 gap-3">
|
<div class="flex justify-end col-span-2 gap-3">
|
||||||
@@ -424,52 +462,93 @@
|
|||||||
rows="3"></textarea>
|
rows="3"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 action-buttons">
|
<div class="flex flex-col sm:flex-row items-stretch sm:items-center justify-end gap-3 action-buttons">
|
||||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-sm hover:shadow-md"
|
<button class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-sm hover:shadow-md touch-target"
|
||||||
@click="showNotes = !showNotes">
|
@click="showNotes = !showNotes"
|
||||||
<i class="mr-2 fas fa-comment-alt"></i>
|
aria-expanded="showNotes"
|
||||||
Add Notes
|
aria-controls="notes-section-{{ submission.id }}">
|
||||||
|
<i class="mr-2 fas fa-comment-alt" aria-hidden="true"></i>
|
||||||
|
<span>Add Notes</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-sm hover:shadow-md"
|
<button class="inline-flex items-center px-4 py-2.5 font-medium text-gray-700 transition-all duration-200 bg-gray-100 rounded-lg hover:bg-gray-200 hover:text-gray-900 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white shadow-sm hover:shadow-md touch-target"
|
||||||
@click="isEditing = !isEditing">
|
@click="isEditing = !isEditing"
|
||||||
<i class="mr-2 fas fa-edit"></i>
|
aria-expanded="isEditing"
|
||||||
Edit
|
aria-controls="edit-form-{{ submission.id }}">
|
||||||
|
<i class="mr-2 fas fa-edit" aria-hidden="true"></i>
|
||||||
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if submission.status != 'ESCALATED' or user.role in 'ADMIN,SUPERUSER' %}
|
{% if submission.status != 'ESCALATED' or user.role in 'ADMIN,SUPERUSER' %}
|
||||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-green-600 rounded-lg hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600 shadow-sm hover:shadow-md"
|
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-green-600 rounded-lg hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600 shadow-sm hover:shadow-md touch-target disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
hx-post="{% url 'moderation:approve_submission' submission.id %}"
|
hx-post="{% url 'moderation:approve_submission' submission.id %}"
|
||||||
hx-target="#submissions-content"
|
hx-target="#submission-{{ submission.id }}"
|
||||||
hx-include="closest .review-notes"
|
hx-include="closest .review-notes"
|
||||||
hx-confirm="Are you sure you want to approve this submission?"
|
hx-confirm="Are you sure you want to approve this submission?"
|
||||||
hx-indicator="#loading-indicator">
|
hx-indicator="#loading-indicator-{{ submission.id }}"
|
||||||
<i class="mr-2 fas fa-check"></i>
|
hx-disabled-elt="this"
|
||||||
Approve
|
hx-swap="outerHTML"
|
||||||
|
hx-on::before-request="this.disabled = true"
|
||||||
|
hx-on::after-request="this.disabled = false"
|
||||||
|
aria-label="Approve submission">
|
||||||
|
<i class="mr-2 fas fa-check" aria-hidden="true"></i>
|
||||||
|
<span>Approve</span>
|
||||||
|
<span class="htmx-indicator ml-2">
|
||||||
|
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600 shadow-sm hover:shadow-md"
|
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-red-600 rounded-lg hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600 shadow-sm hover:shadow-md touch-target disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
hx-post="{% url 'moderation:reject_submission' submission.id %}"
|
hx-post="{% url 'moderation:reject_submission' submission.id %}"
|
||||||
hx-target="#submissions-content"
|
hx-target="#submission-{{ submission.id }}"
|
||||||
hx-include="closest .review-notes"
|
hx-include="closest .review-notes"
|
||||||
hx-confirm="Are you sure you want to reject this submission?"
|
hx-confirm="Are you sure you want to reject this submission?"
|
||||||
hx-indicator="#loading-indicator">
|
hx-indicator="#loading-indicator-{{ submission.id }}"
|
||||||
<i class="mr-2 fas fa-times"></i>
|
hx-disabled-elt="this"
|
||||||
Reject
|
hx-swap="outerHTML"
|
||||||
|
hx-on::before-request="this.disabled = true"
|
||||||
|
hx-on::after-request="this.disabled = false"
|
||||||
|
aria-label="Reject submission">
|
||||||
|
<i class="mr-2 fas fa-times" aria-hidden="true"></i>
|
||||||
|
<span>Reject</span>
|
||||||
|
<span class="htmx-indicator ml-2">
|
||||||
|
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if user.role == 'MODERATOR' and submission.status != 'ESCALATED' %}
|
{% if user.role == 'MODERATOR' and submission.status != 'ESCALATED' %}
|
||||||
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-yellow-600 rounded-lg hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600 shadow-sm hover:shadow-md"
|
<button class="inline-flex items-center px-4 py-2.5 font-medium text-white transition-all duration-200 bg-yellow-600 rounded-lg hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600 shadow-sm hover:shadow-md touch-target disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
hx-post="{% url 'moderation:escalate_submission' submission.id %}"
|
hx-post="{% url 'moderation:escalate_submission' submission.id %}"
|
||||||
hx-target="#submissions-content"
|
hx-target="#submission-{{ submission.id }}"
|
||||||
hx-include="closest .review-notes"
|
hx-include="closest .review-notes"
|
||||||
hx-confirm="Are you sure you want to escalate this submission?"
|
hx-confirm="Are you sure you want to escalate this submission?"
|
||||||
hx-indicator="#loading-indicator">
|
hx-indicator="#loading-indicator-{{ submission.id }}"
|
||||||
<i class="mr-2 fas fa-arrow-up"></i>
|
hx-disabled-elt="this"
|
||||||
Escalate
|
hx-swap="outerHTML"
|
||||||
|
hx-on::before-request="this.disabled = true"
|
||||||
|
hx-on::after-request="this.disabled = false"
|
||||||
|
aria-label="Escalate submission">
|
||||||
|
<i class="mr-2 fas fa-arrow-up" aria-hidden="true"></i>
|
||||||
|
<span>Escalate</span>
|
||||||
|
<span class="htmx-indicator ml-2">
|
||||||
|
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Submission-specific loading indicator -->
|
||||||
|
<div id="loading-indicator-{{ submission.id }}"
|
||||||
|
class="htmx-indicator fixed inset-0 bg-black/20 dark:bg-black/40 flex items-center justify-center z-50"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-xl">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-8 h-8 border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div>
|
||||||
|
<span class="text-gray-900 dark:text-gray-100">Processing...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ urlpatterns = [
|
|||||||
path("user/", accounts_views.user_redirect_view, name="user_redirect"),
|
path("user/", accounts_views.user_redirect_view, name="user_redirect"),
|
||||||
# Moderation URLs - placed after other URLs but before static/media serving
|
# Moderation URLs - placed after other URLs but before static/media serving
|
||||||
path("moderation/", include("moderation.urls", namespace="moderation")),
|
path("moderation/", include("moderation.urls", namespace="moderation")),
|
||||||
|
# Version Control System URLs
|
||||||
|
path("vcs/", include("history_tracking.urls", namespace="history")),
|
||||||
path(
|
path(
|
||||||
"env-settings/",
|
"env-settings/",
|
||||||
views***REMOVED***ironment_and_settings_view,
|
views***REMOVED***ironment_and_settings_view,
|
||||||
|
|||||||
Reference in New Issue
Block a user