mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 16:11:08 -05:00
Revert "Add version control system functionality with branch management, history tracking, and merge operations"
This reverts commit 939eaed201.
This commit is contained in:
@@ -1,3 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
from django.db.models.signals import class_prepared, post_init
|
|
||||||
|
|
||||||
class CommentsConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'comments'
|
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
"""Set up comment system when the app is ready."""
|
|
||||||
pass
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.db import models
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
|
||||||
|
|
||||||
class CommentThreadManager(models.Manager):
|
|
||||||
"""Manager for handling comment threads on both regular and historical models."""
|
|
||||||
|
|
||||||
def for_instance(self, instance):
|
|
||||||
"""Get comment threads for any model instance."""
|
|
||||||
# Get the base model class if this is a historical instance
|
|
||||||
if instance.__class__.__name__.startswith('Historical'):
|
|
||||||
model_class = instance.instance.__class__
|
|
||||||
instance_id = instance.instance.pk
|
|
||||||
else:
|
|
||||||
model_class = instance.__class__
|
|
||||||
instance_id = instance.pk
|
|
||||||
|
|
||||||
ct = ContentType.objects.get_for_model(model_class)
|
|
||||||
return self.filter(content_type=ct, object_id=instance_id)
|
|
||||||
|
|
||||||
def create_for_instance(self, instance, **kwargs):
|
|
||||||
"""Create a comment thread for any model instance."""
|
|
||||||
# Get the base model class if this is a historical instance
|
|
||||||
if instance.__class__.__name__.startswith('Historical'):
|
|
||||||
model_class = instance.instance.__class__
|
|
||||||
instance_id = instance.instance.pk
|
|
||||||
else:
|
|
||||||
model_class = instance.__class__
|
|
||||||
instance_id = instance.pk
|
|
||||||
|
|
||||||
ct = ContentType.objects.get_for_model(model_class)
|
|
||||||
return self.create(content_type=ct, object_id=instance_id, **kwargs)
|
|
||||||
|
|
||||||
class ThreadedModelManager(models.Manager):
|
|
||||||
"""Manager for models that have comment threads."""
|
|
||||||
|
|
||||||
"""Manager for models that have comment threads."""
|
|
||||||
|
|
||||||
def get_comment_threads(self, instance):
|
|
||||||
"""Get comment threads for this instance."""
|
|
||||||
from comments.models import CommentThread
|
|
||||||
if not instance.pk:
|
|
||||||
return CommentThread.objects.none()
|
|
||||||
return CommentThread.objects.for_instance(instance)
|
|
||||||
|
|
||||||
def add_comment_thread(self, instance, **kwargs):
|
|
||||||
"""Create a comment thread for this instance."""
|
|
||||||
from comments.models import CommentThread
|
|
||||||
if not instance.pk:
|
|
||||||
raise ObjectDoesNotExist("Cannot create comment thread for unsaved instance")
|
|
||||||
return CommentThread.objects.create_for_instance(instance, **kwargs)
|
|
||||||
|
|
||||||
def with_comment_threads(self):
|
|
||||||
"""Get all instances with their comment threads."""
|
|
||||||
from comments.models import CommentThread
|
|
||||||
qs = self.get_queryset()
|
|
||||||
content_type = ContentType.objects.get_for_model(self.model)
|
|
||||||
|
|
||||||
# Get comment threads through a subquery
|
|
||||||
threads = CommentThread.objects.filter(
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=models.OuterRef('pk')
|
|
||||||
)
|
|
||||||
return qs.annotate(
|
|
||||||
comment_count=models.Subquery(
|
|
||||||
threads.values('object_id')
|
|
||||||
.annotate(count=models.Count('id'))
|
|
||||||
.values('count'),
|
|
||||||
output_field=models.IntegerField()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
|
|
||||||
from .models import get_comment_threads
|
|
||||||
|
|
||||||
class CommentableMixin:
|
|
||||||
"""
|
|
||||||
Mixin for models that should have comment functionality.
|
|
||||||
Uses composition instead of inheritance to avoid historical model issues.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def comments(self):
|
|
||||||
"""Get comments helper for this instance."""
|
|
||||||
if self.__class__.__name__.startswith('Historical'):
|
|
||||||
# Historical models delegate to their current instance
|
|
||||||
return self.instance.comments
|
|
||||||
return get_comment_threads(self)
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from .managers import CommentThreadManager, ThreadedModelManager
|
|
||||||
|
|
||||||
class CommentThread(models.Model):
|
|
||||||
"""
|
|
||||||
A thread of comments that can be attached to any model instance,
|
|
||||||
including historical versions.
|
|
||||||
"""
|
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
|
||||||
object_id = models.PositiveIntegerField()
|
|
||||||
content_object = GenericForeignKey('content_type', 'object_id')
|
|
||||||
|
|
||||||
title = models.CharField(max_length=255, blank=True)
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
created_by = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
related_name='created_comment_threads'
|
|
||||||
)
|
|
||||||
is_locked = models.BooleanField(default=False)
|
|
||||||
is_hidden = models.BooleanField(default=False)
|
|
||||||
|
|
||||||
objects = CommentThreadManager()
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['content_type', 'object_id']),
|
|
||||||
]
|
|
||||||
ordering = ['-created_at']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Comment Thread on {self.content_object} - {self.title}"
|
|
||||||
|
|
||||||
class CommentThreads:
|
|
||||||
"""
|
|
||||||
Helper class to manage comment threads for a model instance.
|
|
||||||
This is used instead of direct inheritance to avoid historical model issues.
|
|
||||||
"""
|
|
||||||
def __init__(self, instance):
|
|
||||||
self.instance = instance
|
|
||||||
self._info = {}
|
|
||||||
|
|
||||||
def get_info(self):
|
|
||||||
"""Get or compute comment thread information."""
|
|
||||||
if not self._info:
|
|
||||||
ct = ContentType.objects.get_for_model(self.instance.__class__)
|
|
||||||
self._info = {
|
|
||||||
'count': CommentThread.objects.filter(
|
|
||||||
content_type=ct,
|
|
||||||
object_id=self.instance.pk
|
|
||||||
).count(),
|
|
||||||
'content_type': ct,
|
|
||||||
'object_id': self.instance.pk
|
|
||||||
}
|
|
||||||
return self._info
|
|
||||||
|
|
||||||
def get_threads(self):
|
|
||||||
"""Get comment threads for this instance."""
|
|
||||||
info = self.get_info()
|
|
||||||
return CommentThread.objects.filter(
|
|
||||||
content_type=info['content_type'],
|
|
||||||
object_id=info['object_id']
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_thread(self, title='', created_by=None):
|
|
||||||
"""Create a new comment thread for this instance."""
|
|
||||||
info = self.get_info()
|
|
||||||
thread = CommentThread.objects.create(
|
|
||||||
content_type=info['content_type'],
|
|
||||||
object_id=info['object_id'],
|
|
||||||
title=title,
|
|
||||||
created_by=created_by
|
|
||||||
)
|
|
||||||
self._info = {} # Clear cache
|
|
||||||
return thread
|
|
||||||
|
|
||||||
def get_comment_threads(instance):
|
|
||||||
"""Get or create a CommentThreads helper for a model instance."""
|
|
||||||
if not hasattr(instance, '_comment_threads'):
|
|
||||||
instance._comment_threads = CommentThreads(instance)
|
|
||||||
return instance._comment_threads
|
|
||||||
|
|
||||||
class Comment(models.Model):
|
|
||||||
"""Individual comment within a thread."""
|
|
||||||
thread = models.ForeignKey(
|
|
||||||
CommentThread,
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
related_name='comments'
|
|
||||||
)
|
|
||||||
author = models.ForeignKey(
|
|
||||||
settings.AUTH_USER_MODEL,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
related_name='comments'
|
|
||||||
)
|
|
||||||
content = models.TextField()
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
is_edited = models.BooleanField(default=False)
|
|
||||||
is_hidden = models.BooleanField(default=False)
|
|
||||||
parent = models.ForeignKey(
|
|
||||||
'self',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='replies'
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['created_at']
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"Comment by {self.author} on {self.created_at}"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
# This file intentionally left empty - signals have been replaced with direct mixin configuration
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
@@ -1,21 +1,12 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
|
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
|
||||||
from history_tracking.models import HistoricalModel, VersionBranch, ChangeSet
|
|
||||||
from history_tracking.signals import get_current_branch, ChangesetContextManager
|
|
||||||
from comments.mixins import CommentableMixin
|
|
||||||
from media.mixins import PhotoableModel
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from history_tracking.models import HistoricalSlug
|
from history_tracking.models import HistoricalSlug
|
||||||
|
|
||||||
|
class Company(models.Model):
|
||||||
class Company(HistoricalModel, CommentableMixin, PhotoableModel):
|
|
||||||
comments = GenericRelation(
|
|
||||||
'comments.CommentThread') # Explicit relationship
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(max_length=255, unique=True)
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
website = models.URLField(blank=True)
|
website = models.URLField(blank=True)
|
||||||
@@ -31,7 +22,6 @@ class Company(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = 'companies'
|
verbose_name_plural = 'companies'
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
excluded_fields = ['comments'] # Exclude from historical tracking
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.name
|
return self.name
|
||||||
@@ -39,47 +29,7 @@ class Company(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
def save(self, *args, **kwargs) -> None:
|
def save(self, *args, **kwargs) -> None:
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
# Get the branch from context or use default
|
|
||||||
current_branch = get_current_branch()
|
|
||||||
|
|
||||||
if current_branch:
|
|
||||||
# Save in the context of the current branch
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
# If no branch context, save in main branch
|
|
||||||
main_branch, _ = VersionBranch.objects.get_or_create(
|
|
||||||
name='main',
|
|
||||||
defaults={'metadata': {'type': 'default_branch'}}
|
|
||||||
)
|
|
||||||
|
|
||||||
with ChangesetContextManager(branch=main_branch):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_version_info(self) -> dict:
|
|
||||||
"""Get version control information for this company"""
|
|
||||||
content_type = ContentType.objects.get_for_model(self)
|
|
||||||
latest_changes = ChangeSet.objects.filter(
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=self.pk,
|
|
||||||
status='applied'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
active_branches = VersionBranch.objects.filter(
|
|
||||||
changesets__content_type=content_type,
|
|
||||||
changesets__object_id=self.pk,
|
|
||||||
is_active=True
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'latest_changes': latest_changes,
|
|
||||||
'active_branches': active_branches,
|
|
||||||
'current_branch': get_current_branch(),
|
|
||||||
'total_changes': latest_changes.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
|
||||||
return reverse("companies:company_detail", kwargs={"slug": self.slug})
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_slug(cls, slug: str) -> Tuple['Company', bool]:
|
def get_by_slug(cls, slug: str) -> Tuple['Company', bool]:
|
||||||
@@ -98,10 +48,7 @@ class Company(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||||
raise cls.DoesNotExist()
|
raise cls.DoesNotExist()
|
||||||
|
|
||||||
|
class Manufacturer(models.Model):
|
||||||
class Manufacturer(HistoricalModel, CommentableMixin, PhotoableModel):
|
|
||||||
comments = GenericRelation(
|
|
||||||
'comments.CommentThread') # Explicit relationship
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(max_length=255, unique=True)
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
website = models.URLField(blank=True)
|
website = models.URLField(blank=True)
|
||||||
@@ -116,8 +63,6 @@ class Manufacturer(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
excluded_fields = ['comments'] # Exclude from historical tracking
|
|
||||||
history_exclude = ['comments'] # Exclude from historical models
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.name
|
return self.name
|
||||||
@@ -125,47 +70,7 @@ class Manufacturer(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
def save(self, *args, **kwargs) -> None:
|
def save(self, *args, **kwargs) -> None:
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
# Get the branch from context or use default
|
|
||||||
current_branch = get_current_branch()
|
|
||||||
|
|
||||||
if current_branch:
|
|
||||||
# Save in the context of the current branch
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
# If no branch context, save in main branch
|
|
||||||
main_branch, _ = VersionBranch.objects.get_or_create(
|
|
||||||
name='main',
|
|
||||||
defaults={'metadata': {'type': 'default_branch'}}
|
|
||||||
)
|
|
||||||
|
|
||||||
with ChangesetContextManager(branch=main_branch):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_version_info(self) -> dict:
|
|
||||||
"""Get version control information for this manufacturer"""
|
|
||||||
content_type = ContentType.objects.get_for_model(self)
|
|
||||||
latest_changes = ChangeSet.objects.filter(
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=self.pk,
|
|
||||||
status='applied'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
active_branches = VersionBranch.objects.filter(
|
|
||||||
changesets__content_type=content_type,
|
|
||||||
changesets__object_id=self.pk,
|
|
||||||
is_active=True
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'latest_changes': latest_changes,
|
|
||||||
'active_branches': active_branches,
|
|
||||||
'current_branch': get_current_branch(),
|
|
||||||
'total_changes': latest_changes.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
|
||||||
return reverse("companies:manufacturer_detail", kwargs={"slug": self.slug})
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_slug(cls, slug: str) -> Tuple['Manufacturer', bool]:
|
def get_by_slug(cls, slug: str) -> Tuple['Manufacturer', bool]:
|
||||||
@@ -184,10 +89,7 @@ class Manufacturer(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||||
raise cls.DoesNotExist()
|
raise cls.DoesNotExist()
|
||||||
|
|
||||||
|
class Designer(models.Model):
|
||||||
class Designer(HistoricalModel, CommentableMixin, PhotoableModel):
|
|
||||||
comments = GenericRelation(
|
|
||||||
'comments.CommentThread') # Explicit relationship
|
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(max_length=255, unique=True)
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
website = models.URLField(blank=True)
|
website = models.URLField(blank=True)
|
||||||
@@ -208,47 +110,7 @@ class Designer(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
def save(self, *args, **kwargs) -> None:
|
def save(self, *args, **kwargs) -> None:
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
# Get the branch from context or use default
|
|
||||||
current_branch = get_current_branch()
|
|
||||||
|
|
||||||
if current_branch:
|
|
||||||
# Save in the context of the current branch
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
# If no branch context, save in main branch
|
|
||||||
main_branch, _ = VersionBranch.objects.get_or_create(
|
|
||||||
name='main',
|
|
||||||
defaults={'metadata': {'type': 'default_branch'}}
|
|
||||||
)
|
|
||||||
|
|
||||||
with ChangesetContextManager(branch=main_branch):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_version_info(self) -> dict:
|
|
||||||
"""Get version control information for this designer"""
|
|
||||||
content_type = ContentType.objects.get_for_model(self)
|
|
||||||
latest_changes = ChangeSet.objects.filter(
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=self.pk,
|
|
||||||
status='applied'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
active_branches = VersionBranch.objects.filter(
|
|
||||||
changesets__content_type=content_type,
|
|
||||||
changesets__object_id=self.pk,
|
|
||||||
is_active=True
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'latest_changes': latest_changes,
|
|
||||||
'active_branches': active_branches,
|
|
||||||
'current_branch': get_current_branch(),
|
|
||||||
'total_changes': latest_changes.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
|
||||||
return reverse("companies:designer_detail", kwargs={"slug": self.slug})
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_slug(cls, slug: str) -> Tuple['Designer', bool]:
|
def get_by_slug(cls, slug: str) -> Tuple['Designer', bool]:
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}{{ company.name }} - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
<!-- Main Content Column -->
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<!-- Company Information -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">{{ company.name }}</h1>
|
|
||||||
|
|
||||||
{% if company.description %}
|
|
||||||
<div class="prose max-w-none mb-6">
|
|
||||||
{{ company.description|linebreaks }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Company Details -->
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
{% if company.headquarters %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Headquarters</h3>
|
|
||||||
<p class="mt-1">{{ company.headquarters }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if company.website %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Website</h3>
|
|
||||||
<p class="mt-1">
|
|
||||||
<a href="{{ company.website }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">
|
|
||||||
{{ company.website }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Parks Section -->
|
|
||||||
{% if company.parks.exists %}
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Theme Parks</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{% for park in company.parks.all %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
||||||
<h3 class="text-lg font-semibold">
|
|
||||||
<a href="{{ park.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ park.name }}
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">{{ park.get_status_display }}</p>
|
|
||||||
{% if park.formatted_location %}
|
|
||||||
<p class="text-sm text-gray-500 mt-1">{{ park.formatted_location }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Version Control Status -->
|
|
||||||
{% with version_info=park.get_version_info %}
|
|
||||||
{% if version_info.active_branches.count > 1 %}
|
|
||||||
<div class="mt-2 text-sm">
|
|
||||||
<span class="text-yellow-600">
|
|
||||||
{{ version_info.active_branches.count }} active branches
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<!-- Statistics -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Statistics</h2>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Total Parks:</span>
|
|
||||||
<span class="font-medium">{{ company.total_parks }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Total Rides:</span>
|
|
||||||
<span class="font-medium">{{ company.total_rides }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Version Control Info -->
|
|
||||||
{% with version_info=company.get_version_info %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Version Control</h2>
|
|
||||||
<div class="space-y-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Active Branches:</span>
|
|
||||||
<span class="font-medium">{{ version_info.active_branches.count }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Total Changes:</span>
|
|
||||||
<span class="font-medium">{{ version_info.total_changes }}</span>
|
|
||||||
</div>
|
|
||||||
{% if version_info.latest_changes %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600 block mb-2">Recent Changes:</span>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{% for change in version_info.latest_changes|slice:":3" %}
|
|
||||||
<li class="text-gray-700">{{ change.description }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<!-- Metadata -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Details</h2>
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<p><span class="text-gray-600">Created:</span> {{ company.created_at|date:"F j, Y" }}</p>
|
|
||||||
{% if company.created_at != company.updated_at %}
|
|
||||||
<p><span class="text-gray-600">Last updated:</span> {{ company.updated_at|date:"F j, Y" }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Companies - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Companies</h1>
|
|
||||||
<p class="text-gray-600 mt-2">Theme park owners and operators</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
||||||
<form method="get" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label for="min_parks" class="block text-sm font-medium text-gray-700">Minimum Parks</label>
|
|
||||||
<select name="min_parks" id="min_parks" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="">Any</option>
|
|
||||||
{% for i in "12345" %}
|
|
||||||
<option value="{{ i }}" {% if min_parks == i %}selected{% endif %}>{{ i }}+ parks</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="order" class="block text-sm font-medium text-gray-700">Sort By</label>
|
|
||||||
<select name="order" id="order" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="name" {% if order == 'name' %}selected{% endif %}>Name (A-Z)</option>
|
|
||||||
<option value="-name" {% if order == '-name' %}selected{% endif %}>Name (Z-A)</option>
|
|
||||||
<option value="-total_parks" {% if order == '-total_parks' %}selected{% endif %}>Most Parks</option>
|
|
||||||
<option value="-total_rides" {% if order == '-total_rides' %}selected{% endif %}>Most Rides</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end">
|
|
||||||
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded">
|
|
||||||
Apply Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Companies Grid -->
|
|
||||||
{% if companies %}
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{% for company in companies %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
||||||
<div class="p-6">
|
|
||||||
<h2 class="text-xl font-semibold mb-2">
|
|
||||||
<a href="{{ company.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ company.name }}
|
|
||||||
</a>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{% if company.description %}
|
|
||||||
<p class="text-gray-600 text-sm mb-4">{{ company.description|truncatewords:30 }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4 text-sm">
|
|
||||||
{% if company.headquarters %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Headquarters</span>
|
|
||||||
<p class="font-medium">{{ company.headquarters }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if company.website %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Website</span>
|
|
||||||
<p>
|
|
||||||
<a href="{{ company.website }}"
|
|
||||||
class="text-blue-600 hover:underline"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer">
|
|
||||||
Visit Site
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Total Parks</span>
|
|
||||||
<p class="font-medium">{{ company.total_parks }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Total Rides</span>
|
|
||||||
<p class="font-medium">{{ company.total_rides }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Version Control Status -->
|
|
||||||
{% with version_info=company.get_version_info %}
|
|
||||||
{% if version_info.active_branches.count > 1 %}
|
|
||||||
<div class="text-sm">
|
|
||||||
<span class="text-yellow-600">
|
|
||||||
{{ version_info.active_branches.count }} active branches
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{% if is_paginated %}
|
|
||||||
<div class="mt-8 flex justify-center">
|
|
||||||
<nav class="inline-flex rounded-md shadow">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<a href="?page={{ page_obj.previous_page_number }}{% if request.GET.min_parks %}&min_parks={{ request.GET.min_parks }}{% endif %}{% if request.GET.order %}&order={{ request.GET.order }}{% endif %}"
|
|
||||||
class="px-3 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
||||||
Previous
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<a href="?page={{ page_obj.next_page_number }}{% if request.GET.min_parks %}&min_parks={{ request.GET.min_parks }}{% endif %}{% if request.GET.order %}&order={{ request.GET.order }}{% endif %}"
|
|
||||||
class="px-3 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
||||||
Next
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<p class="text-gray-600">No companies found matching your criteria.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}{{ designer.name }} - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
<!-- Main Content Column -->
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<!-- Designer Information -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">{{ designer.name }}</h1>
|
|
||||||
|
|
||||||
{% if designer.description %}
|
|
||||||
<div class="prose max-w-none mb-6">
|
|
||||||
{{ designer.description|linebreaks }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Designer Details -->
|
|
||||||
{% if designer.website %}
|
|
||||||
<div class="mb-4">
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Website</h3>
|
|
||||||
<p class="mt-1">
|
|
||||||
<a href="{{ designer.website }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">
|
|
||||||
{{ designer.website }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Designed Rides Section -->
|
|
||||||
{% if designer.rides.exists %}
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Designed Rides</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{% for ride in designer.rides.all %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
||||||
<h3 class="text-lg font-semibold">
|
|
||||||
<a href="{{ ride.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ ride.name }}
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">
|
|
||||||
at
|
|
||||||
<a href="{{ ride.park.get_absolute_url }}" class="hover:underline">
|
|
||||||
{{ ride.park.name }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{% if ride.manufacturer %}
|
|
||||||
<p class="text-sm text-gray-600 mt-1">
|
|
||||||
Built by
|
|
||||||
<a href="{{ ride.manufacturer.get_absolute_url }}" class="hover:underline">
|
|
||||||
{{ ride.manufacturer.name }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="mt-2">
|
|
||||||
<span class="px-2 py-1 text-xs rounded
|
|
||||||
{% if ride.status == 'OPERATING' %}
|
|
||||||
bg-green-100 text-green-800
|
|
||||||
{% elif ride.status == 'SBNO' %}
|
|
||||||
bg-yellow-100 text-yellow-800
|
|
||||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}
|
|
||||||
bg-blue-100 text-blue-800
|
|
||||||
{% else %}
|
|
||||||
bg-red-100 text-red-800
|
|
||||||
{% endif %}">
|
|
||||||
{{ ride.get_status_display }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Version Control Status -->
|
|
||||||
{% with version_info=ride.get_version_info %}
|
|
||||||
{% if version_info.active_branches.count > 1 %}
|
|
||||||
<div class="mt-2 text-sm">
|
|
||||||
<span class="text-yellow-600">
|
|
||||||
{{ version_info.active_branches.count }} active branches
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<!-- Statistics -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Statistics</h2>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Total Rides:</span>
|
|
||||||
<span class="font-medium">{{ designer.total_rides }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Roller Coasters:</span>
|
|
||||||
<span class="font-medium">{{ designer.total_roller_coasters }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Version Control Info -->
|
|
||||||
{% with version_info=designer.get_version_info %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Version Control</h2>
|
|
||||||
<div class="space-y-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Active Branches:</span>
|
|
||||||
<span class="font-medium">{{ version_info.active_branches.count }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Total Changes:</span>
|
|
||||||
<span class="font-medium">{{ version_info.total_changes }}</span>
|
|
||||||
</div>
|
|
||||||
{% if version_info.latest_changes %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600 block mb-2">Recent Changes:</span>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{% for change in version_info.latest_changes|slice:":3" %}
|
|
||||||
<li class="text-gray-700">{{ change.description }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<!-- Metadata -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Details</h2>
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<p><span class="text-gray-600">Created:</span> {{ designer.created_at|date:"F j, Y" }}</p>
|
|
||||||
{% if designer.created_at != designer.updated_at %}
|
|
||||||
<p><span class="text-gray-600">Last updated:</span> {{ designer.updated_at|date:"F j, Y" }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Designers - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Ride Designers</h1>
|
|
||||||
<p class="text-gray-600 mt-2">Ride and attraction designers and engineers</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
||||||
<form method="get" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label for="min_rides" class="block text-sm font-medium text-gray-700">Minimum Rides</label>
|
|
||||||
<select name="min_rides" id="min_rides" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="">Any</option>
|
|
||||||
{% for i in "12345" %}
|
|
||||||
<option value="{{ i }}" {% if min_rides == i %}selected{% endif %}>{{ i }}+ rides</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="order" class="block text-sm font-medium text-gray-700">Sort By</label>
|
|
||||||
<select name="order" id="order" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="name" {% if order == 'name' %}selected{% endif %}>Name (A-Z)</option>
|
|
||||||
<option value="-name" {% if order == '-name' %}selected{% endif %}>Name (Z-A)</option>
|
|
||||||
<option value="-total_rides" {% if order == '-total_rides' %}selected{% endif %}>Most Rides</option>
|
|
||||||
<option value="-total_roller_coasters" {% if order == '-total_roller_coasters' %}selected{% endif %}>Most Roller Coasters</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end">
|
|
||||||
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded">
|
|
||||||
Apply Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Designers Grid -->
|
|
||||||
{% if designers %}
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{% for designer in designers %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
||||||
<div class="p-6">
|
|
||||||
<h2 class="text-xl font-semibold mb-2">
|
|
||||||
<a href="{{ designer.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ designer.name }}
|
|
||||||
</a>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{% if designer.description %}
|
|
||||||
<p class="text-gray-600 text-sm mb-4">{{ designer.description|truncatewords:30 }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4 text-sm">
|
|
||||||
{% if designer.website %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Website</span>
|
|
||||||
<p>
|
|
||||||
<a href="{{ designer.website }}"
|
|
||||||
class="text-blue-600 hover:underline"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer">
|
|
||||||
Visit Site
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Total Rides</span>
|
|
||||||
<p class="font-medium">{{ designer.total_rides }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Roller Coasters</span>
|
|
||||||
<p class="font-medium">{{ designer.total_roller_coasters }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notable Rides Preview -->
|
|
||||||
{% if designer.rides.exists %}
|
|
||||||
<div class="text-sm text-gray-600 mb-4">
|
|
||||||
<span class="font-medium">Notable Works:</span>
|
|
||||||
<div class="mt-1">
|
|
||||||
{% for ride in designer.rides.all|slice:":3" %}
|
|
||||||
<span class="inline-block bg-gray-100 rounded-full px-3 py-1 text-xs font-medium text-gray-700 mr-2 mb-2">
|
|
||||||
{{ ride.name }}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
{% if designer.rides.count > 3 %}
|
|
||||||
<span class="text-blue-600">+{{ designer.rides.count|add:"-3" }} more</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Version Control Status -->
|
|
||||||
{% with version_info=designer.get_version_info %}
|
|
||||||
{% if version_info.active_branches.count > 1 %}
|
|
||||||
<div class="text-sm">
|
|
||||||
<span class="text-yellow-600">
|
|
||||||
{{ version_info.active_branches.count }} active branches
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{% if is_paginated %}
|
|
||||||
<div class="mt-8 flex justify-center">
|
|
||||||
<nav class="inline-flex rounded-md shadow">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<a href="?page={{ page_obj.previous_page_number }}{% if request.GET.min_rides %}&min_rides={{ request.GET.min_rides }}{% endif %}{% if request.GET.order %}&order={{ request.GET.order }}{% endif %}"
|
|
||||||
class="px-3 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
||||||
Previous
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<a href="?page={{ page_obj.next_page_number }}{% if request.GET.min_rides %}&min_rides={{ request.GET.min_rides }}{% endif %}{% if request.GET.order %}&order={{ request.GET.order }}{% endif %}"
|
|
||||||
class="px-3 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
||||||
Next
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<p class="text-gray-600">No designers found matching your criteria.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}{{ manufacturer.name }} - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
<!-- Main Content Column -->
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<!-- Manufacturer Information -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-4">{{ manufacturer.name }}</h1>
|
|
||||||
|
|
||||||
{% if manufacturer.description %}
|
|
||||||
<div class="prose max-w-none mb-6">
|
|
||||||
{{ manufacturer.description|linebreaks }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Manufacturer Details -->
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
{% if manufacturer.headquarters %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Headquarters</h3>
|
|
||||||
<p class="mt-1">{{ manufacturer.headquarters }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if manufacturer.website %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Website</h3>
|
|
||||||
<p class="mt-1">
|
|
||||||
<a href="{{ manufacturer.website }}" class="text-blue-600 hover:underline" target="_blank" rel="noopener noreferrer">
|
|
||||||
{{ manufacturer.website }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ride Models Section -->
|
|
||||||
{% if manufacturer.ride_models.exists %}
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Ride Models</h2>
|
|
||||||
<div class="grid grid-cols-1 gap-4">
|
|
||||||
{% for model in manufacturer.ride_models.all %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
||||||
<h3 class="text-lg font-semibold">
|
|
||||||
<a href="{{ model.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ model.name }}
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
{% if model.category %}
|
|
||||||
<p class="text-sm text-gray-600 mt-1">{{ model.get_category_display }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% if model.description %}
|
|
||||||
<p class="text-sm text-gray-600 mt-2">{{ model.description|truncatewords:50 }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Version Control Status -->
|
|
||||||
{% with version_info=model.get_version_info %}
|
|
||||||
{% if version_info.active_branches.count > 1 %}
|
|
||||||
<div class="mt-2 text-sm">
|
|
||||||
<span class="text-yellow-600">
|
|
||||||
{{ version_info.active_branches.count }} active branches
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Installed Rides Section -->
|
|
||||||
{% if manufacturer.rides.exists %}
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Installed Rides</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{% for ride in manufacturer.rides.all %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
||||||
<h3 class="text-lg font-semibold">
|
|
||||||
<a href="{{ ride.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ ride.name }}
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">
|
|
||||||
at
|
|
||||||
<a href="{{ ride.park.get_absolute_url }}" class="hover:underline">
|
|
||||||
{{ ride.park.name }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<div class="mt-2">
|
|
||||||
<span class="px-2 py-1 text-xs rounded
|
|
||||||
{% if ride.status == 'OPERATING' %}
|
|
||||||
bg-green-100 text-green-800
|
|
||||||
{% elif ride.status == 'SBNO' %}
|
|
||||||
bg-yellow-100 text-yellow-800
|
|
||||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}
|
|
||||||
bg-blue-100 text-blue-800
|
|
||||||
{% else %}
|
|
||||||
bg-red-100 text-red-800
|
|
||||||
{% endif %}">
|
|
||||||
{{ ride.get_status_display }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Version Control Status -->
|
|
||||||
{% with version_info=ride.get_version_info %}
|
|
||||||
{% if version_info.active_branches.count > 1 %}
|
|
||||||
<div class="mt-2 text-sm">
|
|
||||||
<span class="text-yellow-600">
|
|
||||||
{{ version_info.active_branches.count }} active branches
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<!-- Statistics -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Statistics</h2>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Total Rides:</span>
|
|
||||||
<span class="font-medium">{{ manufacturer.total_rides }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Roller Coasters:</span>
|
|
||||||
<span class="font-medium">{{ manufacturer.total_roller_coasters }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Version Control Info -->
|
|
||||||
{% with version_info=manufacturer.get_version_info %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Version Control</h2>
|
|
||||||
<div class="space-y-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Active Branches:</span>
|
|
||||||
<span class="font-medium">{{ version_info.active_branches.count }}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Total Changes:</span>
|
|
||||||
<span class="font-medium">{{ version_info.total_changes }}</span>
|
|
||||||
</div>
|
|
||||||
{% if version_info.latest_changes %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600 block mb-2">Recent Changes:</span>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{% for change in version_info.latest_changes|slice:":3" %}
|
|
||||||
<li class="text-gray-700">{{ change.description }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<!-- Metadata -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Details</h2>
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<p><span class="text-gray-600">Created:</span> {{ manufacturer.created_at|date:"F j, Y" }}</p>
|
|
||||||
{% if manufacturer.created_at != manufacturer.updated_at %}
|
|
||||||
<p><span class="text-gray-600">Last updated:</span> {{ manufacturer.updated_at|date:"F j, Y" }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Manufacturers - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Manufacturers</h1>
|
|
||||||
<p class="text-gray-600 mt-2">Ride and attraction manufacturers</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
||||||
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<label for="category" class="block text-sm font-medium text-gray-700">Ride Type</label>
|
|
||||||
<select name="category" id="category" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="">All Types</option>
|
|
||||||
{% for code, name in category_choices %}
|
|
||||||
<option value="{{ code }}" {% if category == code %}selected{% endif %}>{{ name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="min_rides" class="block text-sm font-medium text-gray-700">Minimum Rides</label>
|
|
||||||
<select name="min_rides" id="min_rides" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="">Any</option>
|
|
||||||
{% for i in "12345" %}
|
|
||||||
<option value="{{ i }}" {% if min_rides == i %}selected{% endif %}>{{ i }}+ rides</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="order" class="block text-sm font-medium text-gray-700">Sort By</label>
|
|
||||||
<select name="order" id="order" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="name" {% if order == 'name' %}selected{% endif %}>Name (A-Z)</option>
|
|
||||||
<option value="-name" {% if order == '-name' %}selected{% endif %}>Name (Z-A)</option>
|
|
||||||
<option value="-total_rides" {% if order == '-total_rides' %}selected{% endif %}>Most Rides</option>
|
|
||||||
<option value="-total_roller_coasters" {% if order == '-total_roller_coasters' %}selected{% endif %}>Most Roller Coasters</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end">
|
|
||||||
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded">
|
|
||||||
Apply Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Manufacturers Grid -->
|
|
||||||
{% if manufacturers %}
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{% for manufacturer in manufacturers %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
||||||
<div class="p-6">
|
|
||||||
<h2 class="text-xl font-semibold mb-2">
|
|
||||||
<a href="{{ manufacturer.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ manufacturer.name }}
|
|
||||||
</a>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{% if manufacturer.description %}
|
|
||||||
<p class="text-gray-600 text-sm mb-4">{{ manufacturer.description|truncatewords:30 }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4 text-sm">
|
|
||||||
{% if manufacturer.headquarters %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Headquarters</span>
|
|
||||||
<p class="font-medium">{{ manufacturer.headquarters }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if manufacturer.website %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Website</span>
|
|
||||||
<p>
|
|
||||||
<a href="{{ manufacturer.website }}"
|
|
||||||
class="text-blue-600 hover:underline"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer">
|
|
||||||
Visit Site
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Total Rides</span>
|
|
||||||
<p class="font-medium">{{ manufacturer.total_rides }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Roller Coasters</span>
|
|
||||||
<p class="font-medium">{{ manufacturer.total_roller_coasters }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ride Models Preview -->
|
|
||||||
{% if manufacturer.ride_models.exists %}
|
|
||||||
<div class="text-sm text-gray-600 mb-4">
|
|
||||||
<span class="font-medium">Popular Models:</span>
|
|
||||||
<div class="mt-1">
|
|
||||||
{% for model in manufacturer.ride_models.all|slice:":3" %}
|
|
||||||
<span class="inline-block bg-gray-100 rounded-full px-3 py-1 text-xs font-medium text-gray-700 mr-2 mb-2">
|
|
||||||
{{ model.name }}
|
|
||||||
</span>
|
|
||||||
{% endfor %}
|
|
||||||
{% if manufacturer.ride_models.count > 3 %}
|
|
||||||
<span class="text-blue-600">+{{ manufacturer.ride_models.count|add:"-3" }} more</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Version Control Status -->
|
|
||||||
{% with version_info=manufacturer.get_version_info %}
|
|
||||||
{% if version_info.active_branches.count > 1 %}
|
|
||||||
<div class="text-sm">
|
|
||||||
<span class="text-yellow-600">
|
|
||||||
{{ version_info.active_branches.count }} active branches
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{% if is_paginated %}
|
|
||||||
<div class="mt-8 flex justify-center">
|
|
||||||
<nav class="inline-flex rounded-md shadow">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<a href="?page={{ page_obj.previous_page_number }}{% if request.GET.category %}&category={{ request.GET.category }}{% endif %}{% if request.GET.min_rides %}&min_rides={{ request.GET.min_rides }}{% endif %}{% if request.GET.order %}&order={{ request.GET.order }}{% endif %}"
|
|
||||||
class="px-3 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
||||||
Previous
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<a href="?page={{ page_obj.next_page_number }}{% if request.GET.category %}&category={{ request.GET.category }}{% endif %}{% if request.GET.min_rides %}&min_rides={{ request.GET.min_rides }}{% endif %}{% if request.GET.order %}&order={{ request.GET.order }}{% endif %}"
|
|
||||||
class="px-3 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
||||||
Next
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<p class="text-gray-600">No manufacturers found matching your criteria.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
# Version Control System API Documentation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
The version control system provides a comprehensive API for managing content versioning, branching, and merging across different models in the system.
|
|
||||||
|
|
||||||
## Core Models
|
|
||||||
|
|
||||||
### VersionBranch
|
|
||||||
Represents a branch in the version control system.
|
|
||||||
|
|
||||||
```python
|
|
||||||
class VersionBranch:
|
|
||||||
name: str # Branch name (unique)
|
|
||||||
metadata: JSONField # Branch metadata
|
|
||||||
is_active: bool # Branch status
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
```
|
|
||||||
|
|
||||||
### ChangeSet
|
|
||||||
Represents a set of changes to a versioned object.
|
|
||||||
|
|
||||||
```python
|
|
||||||
class ChangeSet:
|
|
||||||
branch: ForeignKey # Reference to VersionBranch
|
|
||||||
content_type: ForeignKey # ContentType of the changed object
|
|
||||||
object_id: int # ID of the changed object
|
|
||||||
data: JSONField # Change data
|
|
||||||
status: str # Status (pending, applied, conflict)
|
|
||||||
created_at: datetime
|
|
||||||
applied_at: datetime
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
### Branch Management
|
|
||||||
|
|
||||||
#### Create Branch
|
|
||||||
```http
|
|
||||||
POST /api/v1/version-control/branches/
|
|
||||||
```
|
|
||||||
|
|
||||||
Request body:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "feature/new-branch",
|
|
||||||
"metadata": {
|
|
||||||
"type": "feature",
|
|
||||||
"description": "New feature branch"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "feature/new-branch",
|
|
||||||
"metadata": {
|
|
||||||
"type": "feature",
|
|
||||||
"description": "New feature branch"
|
|
||||||
},
|
|
||||||
"is_active": true,
|
|
||||||
"created_at": "2025-02-07T09:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Switch Branch
|
|
||||||
```http
|
|
||||||
POST /api/v1/version-control/branches/{branch_id}/switch/
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"branch": {
|
|
||||||
"id": 1,
|
|
||||||
"name": "feature/new-branch"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Change Management
|
|
||||||
|
|
||||||
#### Create Change
|
|
||||||
```http
|
|
||||||
POST /api/v1/version-control/changes/
|
|
||||||
```
|
|
||||||
|
|
||||||
Request body:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"branch_id": 1,
|
|
||||||
"content_type": "parks.park",
|
|
||||||
"object_id": 123,
|
|
||||||
"data": {
|
|
||||||
"name": "Updated Name",
|
|
||||||
"description": "Updated description"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"branch": 1,
|
|
||||||
"status": "pending",
|
|
||||||
"created_at": "2025-02-07T09:05:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Apply Change
|
|
||||||
```http
|
|
||||||
POST /api/v1/version-control/changes/{change_id}/apply/
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"change": {
|
|
||||||
"id": 1,
|
|
||||||
"status": "applied",
|
|
||||||
"applied_at": "2025-02-07T09:06:00Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Merge Operations
|
|
||||||
|
|
||||||
#### Merge Branch
|
|
||||||
```http
|
|
||||||
POST /api/v1/version-control/branches/{source_id}/merge/
|
|
||||||
```
|
|
||||||
|
|
||||||
Request body:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"target_branch_id": 2
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"conflicts": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Resolve Conflicts
|
|
||||||
```http
|
|
||||||
POST /api/v1/version-control/merge/resolve/
|
|
||||||
```
|
|
||||||
|
|
||||||
Request body:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"merge_id": 1,
|
|
||||||
"resolutions": [
|
|
||||||
{
|
|
||||||
"field": "name",
|
|
||||||
"value": "Resolved Name"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Response:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"merge": {
|
|
||||||
"id": 1,
|
|
||||||
"status": "completed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Model Integration
|
|
||||||
|
|
||||||
### Adding Version Control to Models
|
|
||||||
|
|
||||||
To make a model version-controlled, inherit from `HistoricalModel`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from history_tracking.models import HistoricalModel
|
|
||||||
|
|
||||||
class YourModel(HistoricalModel):
|
|
||||||
name = models.CharField(max_length=255)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
# Get the branch from context
|
|
||||||
current_branch = get_current_branch()
|
|
||||||
|
|
||||||
if current_branch:
|
|
||||||
# Save in branch context
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
# Save in main branch
|
|
||||||
with ChangesetContextManager(branch=main_branch):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Version Control Methods
|
|
||||||
|
|
||||||
Each versioned model has access to these methods:
|
|
||||||
|
|
||||||
#### get_version_info()
|
|
||||||
Returns version control information for the object:
|
|
||||||
```python
|
|
||||||
info = model.get_version_info()
|
|
||||||
# Returns:
|
|
||||||
{
|
|
||||||
'latest_changes': [ChangeSet],
|
|
||||||
'active_branches': [VersionBranch],
|
|
||||||
'current_branch': VersionBranch,
|
|
||||||
'total_changes': int
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### get_changes()
|
|
||||||
Returns all changes for the object:
|
|
||||||
```python
|
|
||||||
changes = model.get_changes()
|
|
||||||
# Returns QuerySet of ChangeSet objects
|
|
||||||
```
|
|
||||||
|
|
||||||
## JavaScript Integration
|
|
||||||
|
|
||||||
### Version Control UI
|
|
||||||
|
|
||||||
Initialize version control UI:
|
|
||||||
```javascript
|
|
||||||
import { initVersionControl } from 'version-control.js';
|
|
||||||
|
|
||||||
initVersionControl({
|
|
||||||
container: '#version-control-panel',
|
|
||||||
onChange: (branch) => {
|
|
||||||
// Handle branch change
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Branch Operations
|
|
||||||
|
|
||||||
Switch branches:
|
|
||||||
```javascript
|
|
||||||
import { switchBranch } from 'version-control.js';
|
|
||||||
|
|
||||||
switchBranch(branchId).then(response => {
|
|
||||||
if (response.status === 'success') {
|
|
||||||
// Handle successful branch switch
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Merge Operations
|
|
||||||
|
|
||||||
Handle merge conflicts:
|
|
||||||
```javascript
|
|
||||||
import { handleMergeConflicts } from 'version-control.js';
|
|
||||||
|
|
||||||
handleMergeConflicts(conflicts).then(resolutions => {
|
|
||||||
// Handle conflict resolutions
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
The API uses standard HTTP status codes:
|
|
||||||
|
|
||||||
- 200: Success
|
|
||||||
- 400: Bad Request
|
|
||||||
- 401: Unauthorized
|
|
||||||
- 403: Forbidden
|
|
||||||
- 404: Not Found
|
|
||||||
- 409: Conflict (merge conflicts)
|
|
||||||
- 500: Internal Server Error
|
|
||||||
|
|
||||||
Error responses include detailed information:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "error",
|
|
||||||
"message": "Detailed error message",
|
|
||||||
"code": "ERROR_CODE",
|
|
||||||
"details": {
|
|
||||||
// Additional error details
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rate Limiting
|
|
||||||
|
|
||||||
API endpoints are rate-limited:
|
|
||||||
- Authenticated users: 100 requests per minute
|
|
||||||
- Anonymous users: 20 requests per minute
|
|
||||||
|
|
||||||
Rate limit headers are included in responses:
|
|
||||||
```http
|
|
||||||
X-RateLimit-Limit: 100
|
|
||||||
X-RateLimit-Remaining: 95
|
|
||||||
X-RateLimit-Reset: 1623456789
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
Monitor version control operations through the monitoring dashboard:
|
|
||||||
```http
|
|
||||||
GET /version-control/monitoring/
|
|
||||||
```
|
|
||||||
|
|
||||||
The dashboard provides real-time metrics for:
|
|
||||||
- Branch operations
|
|
||||||
- Merge success rates
|
|
||||||
- Change tracking overhead
|
|
||||||
- Error rates
|
|
||||||
- System health
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
# Version Control User Guide
|
|
||||||
|
|
||||||
## Introduction
|
|
||||||
The version control system allows you to track changes, create branches, and merge content updates across ThrillWiki. This guide explains how to use the version control features effectively.
|
|
||||||
|
|
||||||
## Basic Concepts
|
|
||||||
|
|
||||||
### Branches
|
|
||||||
A branch is a separate line of development that allows you to make changes without affecting the main content. Think of it like a draft version of your content.
|
|
||||||
|
|
||||||
- **Main Branch**: The default branch containing the live, published content
|
|
||||||
- **Feature Branches**: Temporary branches for developing new content or making significant changes
|
|
||||||
- **Active Branch**: The branch you're currently working on
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
Changes represent modifications to content:
|
|
||||||
- Adding new information
|
|
||||||
- Updating existing content
|
|
||||||
- Removing outdated content
|
|
||||||
|
|
||||||
### Merging
|
|
||||||
Merging combines changes from one branch into another, typically from a feature branch back into the main branch.
|
|
||||||
|
|
||||||
## Using Version Control
|
|
||||||
|
|
||||||
### 1. Version Control Panel
|
|
||||||
The version control panel appears at the top of editable pages and shows:
|
|
||||||
- Current branch
|
|
||||||
- Branch selector
|
|
||||||
- Action buttons (Create Branch, Merge, etc.)
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 2. Creating a Branch
|
|
||||||
1. Click "Create Branch" in the version control panel
|
|
||||||
2. Enter a branch name (e.g., "update-park-details")
|
|
||||||
3. Add an optional description
|
|
||||||
4. Click "Create"
|
|
||||||
|
|
||||||
Branch naming conventions:
|
|
||||||
- Use lowercase letters
|
|
||||||
- Separate words with hyphens
|
|
||||||
- Be descriptive (e.g., "add-new-rides", "update-park-history")
|
|
||||||
|
|
||||||
### 3. Switching Branches
|
|
||||||
1. Open the branch selector in the version control panel
|
|
||||||
2. Select the desired branch
|
|
||||||
3. Click "Switch Branch"
|
|
||||||
|
|
||||||
Note: You'll see a warning if you have unsaved changes.
|
|
||||||
|
|
||||||
### 4. Making Changes
|
|
||||||
1. Ensure you're on the correct branch
|
|
||||||
2. Edit content normally
|
|
||||||
3. Save changes
|
|
||||||
4. Changes are tracked automatically
|
|
||||||
|
|
||||||
The version control panel shows:
|
|
||||||
- Number of changes
|
|
||||||
- Last update time
|
|
||||||
- Change status
|
|
||||||
|
|
||||||
### 5. Viewing History
|
|
||||||
1. Click "History" in the version control panel
|
|
||||||
2. See a list of changes with:
|
|
||||||
- Timestamp
|
|
||||||
- Author
|
|
||||||
- Description
|
|
||||||
- Branch
|
|
||||||
3. Click any change to see details
|
|
||||||
|
|
||||||
### 6. Merging Changes
|
|
||||||
1. Switch to the target branch (usually main)
|
|
||||||
2. Click "Merge" in the version control panel
|
|
||||||
3. Select the source branch
|
|
||||||
4. Review changes
|
|
||||||
5. Click "Merge Changes"
|
|
||||||
|
|
||||||
### 7. Handling Conflicts
|
|
||||||
If conflicts occur during merging:
|
|
||||||
1. The conflict resolution dialog appears
|
|
||||||
2. Review conflicting changes
|
|
||||||
3. Choose which version to keep or combine them
|
|
||||||
4. Click "Resolve Conflicts"
|
|
||||||
5. Complete the merge
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### When to Create a Branch
|
|
||||||
Create a new branch when:
|
|
||||||
- Making substantial content updates
|
|
||||||
- Adding new sections
|
|
||||||
- Reorganizing information
|
|
||||||
- Testing new features
|
|
||||||
|
|
||||||
### Branch Management
|
|
||||||
- Keep branches focused on specific tasks
|
|
||||||
- Delete branches after merging
|
|
||||||
- Regular merge changes from main to stay current
|
|
||||||
- Use descriptive branch names
|
|
||||||
|
|
||||||
### Change Management
|
|
||||||
- Make atomic, related changes
|
|
||||||
- Write clear change descriptions
|
|
||||||
- Review changes before merging
|
|
||||||
- Test content in preview mode
|
|
||||||
|
|
||||||
### Collaboration
|
|
||||||
- Communicate branch purpose to team members
|
|
||||||
- Coordinate on shared branches
|
|
||||||
- Review changes before merging
|
|
||||||
- Resolve conflicts together when needed
|
|
||||||
|
|
||||||
## Common Tasks
|
|
||||||
|
|
||||||
### Updating a Park Page
|
|
||||||
1. Create a branch (e.g., "update-park-info")
|
|
||||||
2. Make changes to park information
|
|
||||||
3. Preview changes
|
|
||||||
4. Merge back to main when ready
|
|
||||||
|
|
||||||
### Adding New Rides
|
|
||||||
1. Create a branch (e.g., "add-new-rides-2025")
|
|
||||||
2. Add ride information
|
|
||||||
3. Add photos and details
|
|
||||||
4. Review and merge
|
|
||||||
|
|
||||||
### Content Reorganization
|
|
||||||
1. Create a branch (e.g., "reorganize-sections")
|
|
||||||
2. Rearrange content
|
|
||||||
3. Update navigation
|
|
||||||
4. Test thoroughly
|
|
||||||
5. Merge changes
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
#### Unable to Create Branch
|
|
||||||
- Check permissions
|
|
||||||
- Verify branch name is valid
|
|
||||||
- Ensure no conflicts with existing branches
|
|
||||||
|
|
||||||
#### Merge Conflicts
|
|
||||||
1. Don't panic! Conflicts are normal
|
|
||||||
2. Review both versions carefully
|
|
||||||
3. Choose the best content
|
|
||||||
4. Test after resolving
|
|
||||||
|
|
||||||
#### Lost Changes
|
|
||||||
1. Check branch history
|
|
||||||
2. Review recent changes
|
|
||||||
3. Contact administrator if needed
|
|
||||||
|
|
||||||
### Getting Help
|
|
||||||
- Click the "Help" button in the version control panel
|
|
||||||
- Contact administrators for complex issues
|
|
||||||
- Check documentation for guidance
|
|
||||||
|
|
||||||
## Version Control Status Icons
|
|
||||||
|
|
||||||
| Icon | Meaning |
|
|
||||||
|------|---------|
|
|
||||||
| 🟢 | Current branch |
|
|
||||||
| 🔄 | Pending changes |
|
|
||||||
| ⚠️ | Merge conflicts |
|
|
||||||
| ✅ | Successfully merged |
|
|
||||||
| 🔒 | Protected branch |
|
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
|
||||||
|
|
||||||
| Action | Shortcut |
|
|
||||||
|--------|----------|
|
|
||||||
| Switch Branch | Ctrl/Cmd + B |
|
|
||||||
| Create Branch | Ctrl/Cmd + Shift + B |
|
|
||||||
| View History | Ctrl/Cmd + H |
|
|
||||||
| Merge Branch | Ctrl/Cmd + M |
|
|
||||||
| Save Changes | Ctrl/Cmd + S |
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
- [API Documentation](version_control_api.md)
|
|
||||||
- [Technical Documentation](technical_architecture.md)
|
|
||||||
- [Video Tutorials](https://wiki.thrillwiki.com/tutorials)
|
|
||||||
- [Community Forums](https://community.thrillwiki.com)
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
# Version Control System
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
A comprehensive version control system for Django models that provides branching, merging, and change tracking capabilities with optimized performance through batch processing and caching.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
### System Requirements
|
|
||||||
- Python 3.8+
|
|
||||||
- Django 4.0+
|
|
||||||
- Redis 6.0+ (for caching)
|
|
||||||
- PostgreSQL 12+ (recommended for database)
|
|
||||||
|
|
||||||
### Python Dependencies
|
|
||||||
```
|
|
||||||
django-simple-history>=3.0.0
|
|
||||||
redis>=4.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
1. Add 'history_tracking' to your INSTALLED_APPS:
|
|
||||||
```python
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
...
|
|
||||||
'history_tracking',
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Configure Redis connection in settings.py:
|
|
||||||
```python
|
|
||||||
# Uses existing Redis configuration if available
|
|
||||||
CACHES = {
|
|
||||||
"default": {
|
|
||||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
|
||||||
"LOCATION": "redis://127.0.0.1:6379/1", # Adjust as needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Version control specific settings
|
|
||||||
VERSION_CONTROL = {
|
|
||||||
'CACHE_PREFIX': 'vc_', # Prefix for cache keys
|
|
||||||
'BATCH_SIZE': 100, # Default batch size for operations
|
|
||||||
'MAX_WORKERS': 4, # Maximum parallel workers
|
|
||||||
'CACHE_DURATIONS': { # Cache durations in seconds
|
|
||||||
'BRANCH': 3600, # 1 hour
|
|
||||||
'CHANGE': 1800, # 30 minutes
|
|
||||||
'HISTORY': 86400, # 24 hours
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Run migrations:
|
|
||||||
```bash
|
|
||||||
python manage.py migrate history_tracking
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Making Models Version-Controlled
|
|
||||||
|
|
||||||
1. Inherit from HistoricalModel:
|
|
||||||
```python
|
|
||||||
from history_tracking.models import HistoricalModel
|
|
||||||
|
|
||||||
class YourModel(HistoricalModel):
|
|
||||||
name = models.CharField(max_length=255)
|
|
||||||
description = models.TextField()
|
|
||||||
```
|
|
||||||
|
|
||||||
2. The model will automatically track:
|
|
||||||
- All field changes
|
|
||||||
- Who made changes
|
|
||||||
- When changes were made
|
|
||||||
- Which branch changes were made in
|
|
||||||
|
|
||||||
### Working with Branches
|
|
||||||
|
|
||||||
```python
|
|
||||||
from history_tracking.models import VersionBranch
|
|
||||||
|
|
||||||
# Create a new branch
|
|
||||||
branch = VersionBranch.objects.create(
|
|
||||||
name="feature/new-content",
|
|
||||||
metadata={"type": "feature"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Make changes in branch context
|
|
||||||
from history_tracking.context_processors import branch_context
|
|
||||||
|
|
||||||
with branch_context(branch):
|
|
||||||
your_model.save() # Changes are tracked in the branch
|
|
||||||
```
|
|
||||||
|
|
||||||
### Batch Operations
|
|
||||||
|
|
||||||
For handling multiple changes efficiently:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from history_tracking.batch import BatchOperation
|
|
||||||
|
|
||||||
# Create batch operation
|
|
||||||
batch = BatchOperation(max_workers=4)
|
|
||||||
|
|
||||||
# Add changes to batch
|
|
||||||
for item in items:
|
|
||||||
batch.add_change(item, {'field': 'new_value'})
|
|
||||||
|
|
||||||
# Process changes (parallel or sequential)
|
|
||||||
results = batch.commit(parallel=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Using the Queue System
|
|
||||||
|
|
||||||
For large-scale operations:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from history_tracking.batch import VersionControlQueue
|
|
||||||
|
|
||||||
# Create queue with custom batch size
|
|
||||||
queue = VersionControlQueue(batch_size=100)
|
|
||||||
|
|
||||||
# Queue changes
|
|
||||||
for item in large_dataset:
|
|
||||||
queue.queue_change(item, {'field': 'new_value'})
|
|
||||||
|
|
||||||
# Process queue
|
|
||||||
results = queue.process_queue(parallel=True)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cache Management
|
|
||||||
|
|
||||||
The system automatically caches:
|
|
||||||
- Branch information
|
|
||||||
- Change details
|
|
||||||
- Version history
|
|
||||||
|
|
||||||
Cache invalidation is handled automatically, but you can manually invalidate:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from history_tracking.caching import VersionHistoryCache
|
|
||||||
|
|
||||||
# Invalidate specific caches
|
|
||||||
VersionHistoryCache.invalidate_branch(branch_id)
|
|
||||||
VersionHistoryCache.invalidate_history(content_type_id, object_id)
|
|
||||||
|
|
||||||
# Invalidate all version control caches
|
|
||||||
VersionHistoryCache.invalidate_all()
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
The system includes built-in monitoring:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from history_tracking.monitoring import VersionControlMetrics
|
|
||||||
|
|
||||||
# Collect system metrics
|
|
||||||
VersionControlMetrics.collect_system_metrics()
|
|
||||||
VersionControlMetrics.collect_performance_metrics()
|
|
||||||
```
|
|
||||||
|
|
||||||
Metrics are logged and can be viewed:
|
|
||||||
- In application logs
|
|
||||||
- Through the Django admin interface
|
|
||||||
- Via monitoring endpoints (if configured)
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
The system is optimized for:
|
|
||||||
- Batch processing of changes
|
|
||||||
- Efficient caching of frequently accessed data
|
|
||||||
- Parallel processing capabilities
|
|
||||||
- Minimal database queries
|
|
||||||
|
|
||||||
For large-scale operations:
|
|
||||||
- Use batch processing
|
|
||||||
- Enable parallel processing when appropriate
|
|
||||||
- Configure cache durations based on your needs
|
|
||||||
- Monitor performance metrics
|
|
||||||
|
|
||||||
## Security
|
|
||||||
|
|
||||||
The system integrates with Django's authentication and permissions:
|
|
||||||
- All changes are tracked with user information
|
|
||||||
- Branch access can be controlled
|
|
||||||
- Merge operations can require approval
|
|
||||||
|
|
||||||
## Templates
|
|
||||||
|
|
||||||
The system includes template tags for displaying version control information:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{% load version_control_tags %}
|
|
||||||
|
|
||||||
{% version_status object %}
|
|
||||||
{% branch_selector %}
|
|
||||||
{% history_list object %}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API Endpoints
|
|
||||||
|
|
||||||
Documentation for API endpoints can be found in `docs/version_control_api.md`.
|
|
||||||
|
|
||||||
## Database Considerations
|
|
||||||
|
|
||||||
The system uses your existing Django database configuration and creates these main tables:
|
|
||||||
- history_tracking_versionbranch
|
|
||||||
- history_tracking_changeset
|
|
||||||
- history_tracking_versiontag
|
|
||||||
- history_tracking_commentthread
|
|
||||||
|
|
||||||
Plus historical tables for each tracked model.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
Common issues and solutions:
|
|
||||||
|
|
||||||
1. Performance Issues
|
|
||||||
- Check batch sizes
|
|
||||||
- Verify cache configuration
|
|
||||||
- Monitor database queries
|
|
||||||
- Review parallel processing settings
|
|
||||||
|
|
||||||
2. Cache Issues
|
|
||||||
- Verify Redis connection
|
|
||||||
- Check cache key conflicts
|
|
||||||
- Monitor cache hit rates
|
|
||||||
|
|
||||||
3. Database Issues
|
|
||||||
- Check indexing
|
|
||||||
- Monitor query performance
|
|
||||||
- Review database connection pool settings
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Contributions are welcome! Please read our contributing guidelines and submit pull requests.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
|
||||||
@@ -1,9 +1,26 @@
|
|||||||
|
# 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):
|
||||||
"""Register signals when the app is ready"""
|
from django.apps import apps
|
||||||
from . import signals # Import signals to register them
|
from .mixins import HistoricalChangeMixin
|
||||||
|
|
||||||
|
# Get the Park model
|
||||||
|
try:
|
||||||
|
Park = apps.get_model('parks', 'Park')
|
||||||
|
ParkArea = apps.get_model('parks', 'ParkArea')
|
||||||
|
|
||||||
|
# Apply mixin to historical models
|
||||||
|
if HistoricalChangeMixin not in Park.history.model.__bases__:
|
||||||
|
Park.history.model.__bases__ = (HistoricalChangeMixin,) + Park.history.model.__bases__
|
||||||
|
|
||||||
|
if HistoricalChangeMixin not in ParkArea.history.model.__bases__:
|
||||||
|
ParkArea.history.model.__bases__ = (HistoricalChangeMixin,) + ParkArea.history.model.__bases__
|
||||||
|
except LookupError:
|
||||||
|
# Models might not be loaded yet
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
from django.db import transaction
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.utils import timezone
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from .models import VersionBranch, ChangeSet
|
|
||||||
from .caching import VersionHistoryCache
|
|
||||||
from .signals import get_current_branch
|
|
||||||
|
|
||||||
logger = logging.getLogger('version_control')
|
|
||||||
|
|
||||||
class BatchOperation:
|
|
||||||
"""
|
|
||||||
Handles batch operations for version control system.
|
|
||||||
Provides efficient handling of multiple changes and updates.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, max_workers: int = 4):
|
|
||||||
self.max_workers = max_workers
|
|
||||||
self.changes: List[Dict[str, Any]] = []
|
|
||||||
self.error_handler = self.default_error_handler
|
|
||||||
|
|
||||||
def default_error_handler(self, error: Exception, item: Dict[str, Any]) -> None:
|
|
||||||
"""Default error handling for batch operations"""
|
|
||||||
logger.error(f"Batch operation error: {error}, item: {item}")
|
|
||||||
raise error
|
|
||||||
|
|
||||||
def set_error_handler(self, handler) -> None:
|
|
||||||
"""Set custom error handler for batch operations"""
|
|
||||||
self.error_handler = handler
|
|
||||||
|
|
||||||
def add_change(self, obj: Any, data: Dict[str, Any], branch: Optional[VersionBranch] = None) -> None:
|
|
||||||
"""Add a change to the batch"""
|
|
||||||
content_type = ContentType.objects.get_for_model(obj)
|
|
||||||
self.changes.append({
|
|
||||||
'content_type': content_type,
|
|
||||||
'object_id': obj.pk,
|
|
||||||
'data': data,
|
|
||||||
'branch': branch or get_current_branch()
|
|
||||||
})
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def process_change(self, change: Dict[str, Any]) -> ChangeSet:
|
|
||||||
"""Process a single change in the batch"""
|
|
||||||
try:
|
|
||||||
changeset = ChangeSet.objects.create(
|
|
||||||
branch=change['branch'],
|
|
||||||
content_type=change['content_type'],
|
|
||||||
object_id=change['object_id'],
|
|
||||||
data=change['data'],
|
|
||||||
status='pending'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply the change
|
|
||||||
changeset.apply()
|
|
||||||
|
|
||||||
# Cache the result
|
|
||||||
VersionHistoryCache.cache_change(changeset.to_dict())
|
|
||||||
|
|
||||||
return changeset
|
|
||||||
except Exception as e:
|
|
||||||
self.error_handler(e, change)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def process_parallel(self) -> List[ChangeSet]:
|
|
||||||
"""Process changes in parallel using thread pool"""
|
|
||||||
results = []
|
|
||||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
|
||||||
future_to_change = {
|
|
||||||
executor.submit(self.process_change, change): change
|
|
||||||
for change in self.changes
|
|
||||||
}
|
|
||||||
|
|
||||||
for future in future_to_change:
|
|
||||||
try:
|
|
||||||
changeset = future.result()
|
|
||||||
results.append(changeset)
|
|
||||||
except Exception as e:
|
|
||||||
change = future_to_change[future]
|
|
||||||
self.error_handler(e, change)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def process_sequential(self) -> List[ChangeSet]:
|
|
||||||
"""Process changes sequentially in a single transaction"""
|
|
||||||
results = []
|
|
||||||
for change in self.changes:
|
|
||||||
try:
|
|
||||||
changeset = self.process_change(change)
|
|
||||||
results.append(changeset)
|
|
||||||
except Exception as e:
|
|
||||||
self.error_handler(e, change)
|
|
||||||
|
|
||||||
return results
|
|
||||||
|
|
||||||
def commit(self, parallel: bool = False) -> List[ChangeSet]:
|
|
||||||
"""Commit all changes in the batch"""
|
|
||||||
if not self.changes:
|
|
||||||
return []
|
|
||||||
|
|
||||||
start_time = timezone.now()
|
|
||||||
logger.info(f"Starting batch operation with {len(self.changes)} changes")
|
|
||||||
|
|
||||||
try:
|
|
||||||
results = self.process_parallel() if parallel else self.process_sequential()
|
|
||||||
|
|
||||||
duration = (timezone.now() - start_time).total_seconds()
|
|
||||||
logger.info(
|
|
||||||
f"Batch operation completed: {len(results)} changes processed in {duration:.2f}s"
|
|
||||||
)
|
|
||||||
|
|
||||||
return results
|
|
||||||
finally:
|
|
||||||
self.changes = [] # Clear the batch
|
|
||||||
|
|
||||||
class BulkVersionControl:
|
|
||||||
"""
|
|
||||||
Handles bulk version control operations for collections of objects.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, model_class, branch: Optional[VersionBranch] = None):
|
|
||||||
self.model_class = model_class
|
|
||||||
self.branch = branch or get_current_branch()
|
|
||||||
self.content_type = ContentType.objects.get_for_model(model_class)
|
|
||||||
self.batch = BatchOperation()
|
|
||||||
|
|
||||||
def prepare_bulk_update(self, objects: List[Any], data: Dict[str, Any]) -> None:
|
|
||||||
"""Prepare bulk update for multiple objects"""
|
|
||||||
for obj in objects:
|
|
||||||
self.batch.add_change(obj, data, self.branch)
|
|
||||||
|
|
||||||
def prepare_bulk_delete(self, objects: List[Any]) -> None:
|
|
||||||
"""Prepare bulk delete for multiple objects"""
|
|
||||||
for obj in objects:
|
|
||||||
self.batch.add_change(obj, {'action': 'delete'}, self.branch)
|
|
||||||
|
|
||||||
def prepare_bulk_create(self, data_list: List[Dict[str, Any]]) -> None:
|
|
||||||
"""Prepare bulk create for multiple objects"""
|
|
||||||
for data in data_list:
|
|
||||||
# Create temporary object for content type
|
|
||||||
temp_obj = self.model_class()
|
|
||||||
self.batch.add_change(temp_obj, {'action': 'create', **data}, self.branch)
|
|
||||||
|
|
||||||
def commit(self, parallel: bool = True) -> List[ChangeSet]:
|
|
||||||
"""Commit all prepared bulk operations"""
|
|
||||||
return self.batch.commit(parallel=parallel)
|
|
||||||
|
|
||||||
class VersionControlQueue:
|
|
||||||
"""
|
|
||||||
Queue system for handling version control operations.
|
|
||||||
Allows for delayed processing and batching of changes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, batch_size: int = 100, auto_commit: bool = True):
|
|
||||||
self.batch_size = batch_size
|
|
||||||
self.auto_commit = auto_commit
|
|
||||||
self.current_batch = BatchOperation()
|
|
||||||
self._queued_count = 0
|
|
||||||
|
|
||||||
def queue_change(self, obj: Any, data: Dict[str, Any], branch: Optional[VersionBranch] = None) -> None:
|
|
||||||
"""Queue a change for processing"""
|
|
||||||
self.current_batch.add_change(obj, data, branch)
|
|
||||||
self._queued_count += 1
|
|
||||||
|
|
||||||
if self.auto_commit and self._queued_count >= self.batch_size:
|
|
||||||
self.process_queue()
|
|
||||||
|
|
||||||
def process_queue(self, parallel: bool = True) -> List[ChangeSet]:
|
|
||||||
"""Process all queued changes"""
|
|
||||||
if not self._queued_count:
|
|
||||||
return []
|
|
||||||
|
|
||||||
results = self.current_batch.commit(parallel=parallel)
|
|
||||||
self._queued_count = 0
|
|
||||||
return results
|
|
||||||
|
|
||||||
def batch_version_control(func):
|
|
||||||
"""
|
|
||||||
Decorator for batching version control operations within a function.
|
|
||||||
"""
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
batch = BatchOperation()
|
|
||||||
try:
|
|
||||||
with transaction.atomic():
|
|
||||||
result = func(*args, batch=batch, **kwargs)
|
|
||||||
if batch.changes:
|
|
||||||
batch.commit()
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Batch operation failed: {e}")
|
|
||||||
raise
|
|
||||||
return wrapper
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
from django.core.cache import cache
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from typing import Optional, List, Dict, Any
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger('version_control')
|
|
||||||
|
|
||||||
class VersionHistoryCache:
|
|
||||||
"""
|
|
||||||
Caching system for version control history data.
|
|
||||||
Implements a multi-level caching strategy with memory and persistent storage.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Cache key prefixes
|
|
||||||
BRANCH_PREFIX = 'vc_branch_'
|
|
||||||
CHANGE_PREFIX = 'vc_change_'
|
|
||||||
HISTORY_PREFIX = 'vc_history_'
|
|
||||||
|
|
||||||
# Cache durations (in seconds)
|
|
||||||
BRANCH_CACHE_DURATION = 3600 # 1 hour
|
|
||||||
CHANGE_CACHE_DURATION = 1800 # 30 minutes
|
|
||||||
HISTORY_CACHE_DURATION = 3600 * 24 # 24 hours
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_branch_key(cls, branch_id: int) -> str:
|
|
||||||
"""Generate cache key for branch data"""
|
|
||||||
return f"{cls.BRANCH_PREFIX}{branch_id}"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_change_key(cls, change_id: int) -> str:
|
|
||||||
"""Generate cache key for change data"""
|
|
||||||
return f"{cls.CHANGE_PREFIX}{change_id}"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_history_key(cls, content_type_id: int, object_id: int) -> str:
|
|
||||||
"""Generate cache key for object history"""
|
|
||||||
return f"{cls.HISTORY_PREFIX}{content_type_id}_{object_id}"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def generate_cache_version(cls, data: Dict[str, Any]) -> str:
|
|
||||||
"""Generate version hash for cache invalidation"""
|
|
||||||
data_str = json.dumps(data, sort_keys=True)
|
|
||||||
return hashlib.md5(data_str.encode()).hexdigest()
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def cache_branch(cls, branch_data: Dict[str, Any]) -> None:
|
|
||||||
"""Cache branch data with versioning"""
|
|
||||||
key = cls.get_branch_key(branch_data['id'])
|
|
||||||
version = cls.generate_cache_version(branch_data)
|
|
||||||
|
|
||||||
cache_data = {
|
|
||||||
'data': branch_data,
|
|
||||||
'version': version,
|
|
||||||
'timestamp': settings.VERSION_CONTROL_TIMESTAMP
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
cache.set(key, cache_data, cls.BRANCH_CACHE_DURATION)
|
|
||||||
logger.debug(f"Cached branch data: {key}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error caching branch data: {e}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_cached_branch(cls, branch_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Retrieve cached branch data if valid"""
|
|
||||||
key = cls.get_branch_key(branch_id)
|
|
||||||
cache_data = cache.get(key)
|
|
||||||
|
|
||||||
if cache_data:
|
|
||||||
# Validate cache version and timestamp
|
|
||||||
if (
|
|
||||||
cache_data.get('timestamp') == settings.VERSION_CONTROL_TIMESTAMP and
|
|
||||||
cls.generate_cache_version(cache_data['data']) == cache_data['version']
|
|
||||||
):
|
|
||||||
logger.debug(f"Cache hit for branch: {key}")
|
|
||||||
return cache_data['data']
|
|
||||||
|
|
||||||
# Invalid cache, delete it
|
|
||||||
cache.delete(key)
|
|
||||||
logger.debug(f"Invalidated branch cache: {key}")
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def cache_change(cls, change_data: Dict[str, Any]) -> None:
|
|
||||||
"""Cache change data"""
|
|
||||||
key = cls.get_change_key(change_data['id'])
|
|
||||||
version = cls.generate_cache_version(change_data)
|
|
||||||
|
|
||||||
cache_data = {
|
|
||||||
'data': change_data,
|
|
||||||
'version': version,
|
|
||||||
'timestamp': settings.VERSION_CONTROL_TIMESTAMP
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
cache.set(key, cache_data, cls.CHANGE_CACHE_DURATION)
|
|
||||||
logger.debug(f"Cached change data: {key}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error caching change data: {e}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_cached_change(cls, change_id: int) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Retrieve cached change data if valid"""
|
|
||||||
key = cls.get_change_key(change_id)
|
|
||||||
cache_data = cache.get(key)
|
|
||||||
|
|
||||||
if cache_data:
|
|
||||||
if (
|
|
||||||
cache_data.get('timestamp') == settings.VERSION_CONTROL_TIMESTAMP and
|
|
||||||
cls.generate_cache_version(cache_data['data']) == cache_data['version']
|
|
||||||
):
|
|
||||||
logger.debug(f"Cache hit for change: {key}")
|
|
||||||
return cache_data['data']
|
|
||||||
|
|
||||||
cache.delete(key)
|
|
||||||
logger.debug(f"Invalidated change cache: {key}")
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def cache_history(cls, content_type_id: int, object_id: int, history_data: List[Dict[str, Any]]) -> None:
|
|
||||||
"""Cache version history for an object"""
|
|
||||||
key = cls.get_history_key(content_type_id, object_id)
|
|
||||||
version = cls.generate_cache_version({'history': history_data})
|
|
||||||
|
|
||||||
cache_data = {
|
|
||||||
'data': history_data,
|
|
||||||
'version': version,
|
|
||||||
'timestamp': settings.VERSION_CONTROL_TIMESTAMP
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
cache.set(key, cache_data, cls.HISTORY_CACHE_DURATION)
|
|
||||||
logger.debug(f"Cached history data: {key}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error caching history data: {e}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_cached_history(cls, content_type_id: int, object_id: int) -> Optional[List[Dict[str, Any]]]:
|
|
||||||
"""Retrieve cached history data if valid"""
|
|
||||||
key = cls.get_history_key(content_type_id, object_id)
|
|
||||||
cache_data = cache.get(key)
|
|
||||||
|
|
||||||
if cache_data:
|
|
||||||
if (
|
|
||||||
cache_data.get('timestamp') == settings.VERSION_CONTROL_TIMESTAMP and
|
|
||||||
cls.generate_cache_version({'history': cache_data['data']}) == cache_data['version']
|
|
||||||
):
|
|
||||||
logger.debug(f"Cache hit for history: {key}")
|
|
||||||
return cache_data['data']
|
|
||||||
|
|
||||||
cache.delete(key)
|
|
||||||
logger.debug(f"Invalidated history cache: {key}")
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def invalidate_branch(cls, branch_id: int) -> None:
|
|
||||||
"""Invalidate branch cache"""
|
|
||||||
key = cls.get_branch_key(branch_id)
|
|
||||||
cache.delete(key)
|
|
||||||
logger.debug(f"Manually invalidated branch cache: {key}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def invalidate_change(cls, change_id: int) -> None:
|
|
||||||
"""Invalidate change cache"""
|
|
||||||
key = cls.get_change_key(change_id)
|
|
||||||
cache.delete(key)
|
|
||||||
logger.debug(f"Manually invalidated change cache: {key}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def invalidate_history(cls, content_type_id: int, object_id: int) -> None:
|
|
||||||
"""Invalidate history cache"""
|
|
||||||
key = cls.get_history_key(content_type_id, object_id)
|
|
||||||
cache.delete(key)
|
|
||||||
logger.debug(f"Manually invalidated history cache: {key}")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def invalidate_all(cls) -> None:
|
|
||||||
"""Invalidate all version control caches"""
|
|
||||||
try:
|
|
||||||
# Get all keys with our prefixes
|
|
||||||
keys = []
|
|
||||||
for prefix in [cls.BRANCH_PREFIX, cls.CHANGE_PREFIX, cls.HISTORY_PREFIX]:
|
|
||||||
keys.extend(cache.keys(f"{prefix}*"))
|
|
||||||
|
|
||||||
# Delete all matching keys
|
|
||||||
cache.delete_many(keys)
|
|
||||||
logger.info(f"Invalidated {len(keys)} version control cache entries")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error invalidating all caches: {e}")
|
|
||||||
|
|
||||||
class CacheableVersionMixin:
|
|
||||||
"""Mixin to add caching capabilities to version control models"""
|
|
||||||
|
|
||||||
def cache_data(self) -> None:
|
|
||||||
"""Cache the object's data"""
|
|
||||||
if hasattr(self, 'to_dict'):
|
|
||||||
data = self.to_dict()
|
|
||||||
|
|
||||||
if hasattr(self, 'branch_id'):
|
|
||||||
VersionHistoryCache.cache_branch(data)
|
|
||||||
elif hasattr(self, 'change_id'):
|
|
||||||
VersionHistoryCache.cache_change(data)
|
|
||||||
|
|
||||||
def invalidate_cache(self) -> None:
|
|
||||||
"""Invalidate the object's cache"""
|
|
||||||
if hasattr(self, 'branch_id'):
|
|
||||||
VersionHistoryCache.invalidate_branch(self.branch_id)
|
|
||||||
elif hasattr(self, 'change_id'):
|
|
||||||
VersionHistoryCache.invalidate_change(self.change_id)
|
|
||||||
|
|
||||||
def invalidate_related_caches(self) -> None:
|
|
||||||
"""Invalidate related object caches"""
|
|
||||||
if hasattr(self, 'content_type_id') and hasattr(self, 'object_id'):
|
|
||||||
VersionHistoryCache.invalidate_history(
|
|
||||||
self.content_type_id,
|
|
||||||
self.object_id
|
|
||||||
)
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
from django.db import transaction
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
from datetime import timedelta
|
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
from .models import VersionBranch, ChangeSet
|
|
||||||
from .caching import VersionHistoryCache
|
|
||||||
|
|
||||||
logger = logging.getLogger('version_control')
|
|
||||||
|
|
||||||
|
|
||||||
class VersionCleanup:
|
|
||||||
"""
|
|
||||||
Manages cleanup of old version control data through archival and deletion.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.archive_path = getattr(
|
|
||||||
settings,
|
|
||||||
'VERSION_CONTROL_ARCHIVE_PATH',
|
|
||||||
'version_archives'
|
|
||||||
)
|
|
||||||
self.retention_days = getattr(
|
|
||||||
settings,
|
|
||||||
'VERSION_CONTROL_RETENTION_DAYS',
|
|
||||||
90
|
|
||||||
)
|
|
||||||
self.merged_retention_days = getattr(
|
|
||||||
settings,
|
|
||||||
'VERSION_CONTROL_MERGED_RETENTION_DAYS',
|
|
||||||
30
|
|
||||||
)
|
|
||||||
self.ensure_archive_directory()
|
|
||||||
|
|
||||||
def ensure_archive_directory(self) -> None:
|
|
||||||
"""Ensure archive directory exists"""
|
|
||||||
if not os.path.exists(self.archive_path):
|
|
||||||
os.makedirs(self.archive_path)
|
|
||||||
|
|
||||||
def get_archive_filename(self, date: timezone.datetime) -> str:
|
|
||||||
"""Generate archive filename for a given date"""
|
|
||||||
return os.path.join(
|
|
||||||
self.archive_path,
|
|
||||||
f'version_archive_{date.strftime("%Y%m%d_%H%M%S")}.json'
|
|
||||||
)
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def archive_old_changes(self, batch_size: int = 1000) -> int:
|
|
||||||
"""Archive and clean up old changes"""
|
|
||||||
cutoff_date = timezone.now() - timedelta(days=self.retention_days)
|
|
||||||
|
|
||||||
# Get changes to archive
|
|
||||||
old_changes = ChangeSet.objects.filter(
|
|
||||||
created_at__lt=cutoff_date,
|
|
||||||
archived=False
|
|
||||||
)[:batch_size]
|
|
||||||
|
|
||||||
if not old_changes:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Prepare archive data
|
|
||||||
archive_data = {
|
|
||||||
'timestamp': timezone.now().isoformat(),
|
|
||||||
'changes': [
|
|
||||||
{
|
|
||||||
'id': change.id,
|
|
||||||
'branch': change.branch_id,
|
|
||||||
'content_type': change.content_type_id,
|
|
||||||
'object_id': change.object_id,
|
|
||||||
'data': change.data,
|
|
||||||
'status': change.status,
|
|
||||||
'created_at': change.created_at.isoformat(),
|
|
||||||
'applied_at': change.applied_at.isoformat() if change.applied_at else None
|
|
||||||
}
|
|
||||||
for change in old_changes
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Write to archive file
|
|
||||||
archive_file = self.get_archive_filename(timezone.now())
|
|
||||||
with open(archive_file, 'w') as f:
|
|
||||||
json.dump(archive_data, f, indent=2)
|
|
||||||
|
|
||||||
# Mark changes as archived
|
|
||||||
change_ids = [change.id for change in old_changes]
|
|
||||||
ChangeSet.objects.filter(id__in=change_ids).update(archived=True)
|
|
||||||
|
|
||||||
logger.info(f"Archived {len(change_ids)} changes to {archive_file}")
|
|
||||||
return len(change_ids)
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def cleanup_merged_branches(self) -> int:
|
|
||||||
"""Clean up old merged branches"""
|
|
||||||
cutoff_date = timezone.now() - timedelta(days=self.merged_retention_days)
|
|
||||||
|
|
||||||
# Find merged branches to clean up
|
|
||||||
merged_branches = VersionBranch.objects.filter(
|
|
||||||
is_merged=True,
|
|
||||||
merged_at__lt=cutoff_date,
|
|
||||||
is_protected=False
|
|
||||||
)
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for branch in merged_branches:
|
|
||||||
try:
|
|
||||||
# Archive branch changes
|
|
||||||
self.archive_branch_changes(branch)
|
|
||||||
|
|
||||||
# Delete branch
|
|
||||||
branch.delete()
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
logger.info(f"Cleaned up merged branch: {branch.name}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error cleaning up branch {branch.name}: {e}")
|
|
||||||
|
|
||||||
return count
|
|
||||||
|
|
||||||
def archive_branch_changes(self, branch: VersionBranch) -> None:
|
|
||||||
"""Archive all changes for a specific branch"""
|
|
||||||
changes = ChangeSet.objects.filter(
|
|
||||||
branch=branch,
|
|
||||||
archived=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if not changes:
|
|
||||||
return
|
|
||||||
|
|
||||||
archive_data = {
|
|
||||||
'timestamp': timezone.now().isoformat(),
|
|
||||||
'branch': {
|
|
||||||
'id': branch.id,
|
|
||||||
'name': branch.name,
|
|
||||||
'metadata': branch.metadata,
|
|
||||||
'created_at': branch.created_at.isoformat(),
|
|
||||||
'merged_at': branch.merged_at.isoformat() if branch.merged_at else None
|
|
||||||
},
|
|
||||||
'changes': [
|
|
||||||
{
|
|
||||||
'id': change.id,
|
|
||||||
'content_type': change.content_type_id,
|
|
||||||
'object_id': change.object_id,
|
|
||||||
'data': change.data,
|
|
||||||
'status': change.status,
|
|
||||||
'created_at': change.created_at.isoformat(),
|
|
||||||
'applied_at': change.applied_at.isoformat() if change.applied_at else None
|
|
||||||
}
|
|
||||||
for change in changes
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Write to archive file
|
|
||||||
archive_file = self.get_archive_filename(timezone.now())
|
|
||||||
with open(archive_file, 'w') as f:
|
|
||||||
json.dump(archive_data, f, indent=2)
|
|
||||||
|
|
||||||
# Mark changes as archived
|
|
||||||
changes.update(archived=True)
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def cleanup_inactive_branches(self, days: int = 60) -> int:
|
|
||||||
"""Clean up inactive branches"""
|
|
||||||
cutoff_date = timezone.now() - timedelta(days=days)
|
|
||||||
|
|
||||||
# Find inactive branches
|
|
||||||
inactive_branches = VersionBranch.objects.filter(
|
|
||||||
is_active=True,
|
|
||||||
is_protected=False,
|
|
||||||
updated_at__lt=cutoff_date
|
|
||||||
)
|
|
||||||
|
|
||||||
count = 0
|
|
||||||
for branch in inactive_branches:
|
|
||||||
try:
|
|
||||||
# Archive branch changes
|
|
||||||
self.archive_branch_changes(branch)
|
|
||||||
|
|
||||||
# Deactivate branch
|
|
||||||
branch.is_active = False
|
|
||||||
branch.save()
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
logger.info(f"Deactivated inactive branch: {branch.name}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error deactivating branch {branch.name}: {e}")
|
|
||||||
|
|
||||||
return count
|
|
||||||
|
|
||||||
def cleanup_orphaned_changes(self) -> int:
|
|
||||||
"""Clean up changes without valid content objects"""
|
|
||||||
count = 0
|
|
||||||
for change in ChangeSet.objects.filter(archived=False):
|
|
||||||
try:
|
|
||||||
# Try to get the related object
|
|
||||||
obj = change.content_type.get_object_for_this_type(
|
|
||||||
pk=change.object_id)
|
|
||||||
if obj is None:
|
|
||||||
self.archive_change(change)
|
|
||||||
count += 1
|
|
||||||
except Exception:
|
|
||||||
# If object doesn't exist, archive the change
|
|
||||||
self.archive_change(change)
|
|
||||||
count += 1
|
|
||||||
|
|
||||||
logger.info(f"Cleaned up {count} orphaned changes")
|
|
||||||
return count
|
|
||||||
|
|
||||||
def archive_change(self, change: ChangeSet) -> None:
|
|
||||||
"""Archive a single change"""
|
|
||||||
archive_data = {
|
|
||||||
'timestamp': timezone.now().isoformat(),
|
|
||||||
'changes': [{
|
|
||||||
'id': change.id,
|
|
||||||
'branch': change.branch_id,
|
|
||||||
'content_type': change.content_type_id,
|
|
||||||
'object_id': change.object_id,
|
|
||||||
'data': change.data,
|
|
||||||
'status': change.status,
|
|
||||||
'created_at': change.created_at.isoformat(),
|
|
||||||
'applied_at': change.applied_at.isoformat() if change.applied_at else None
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Write to archive file
|
|
||||||
archive_file = self.get_archive_filename(timezone.now())
|
|
||||||
with open(archive_file, 'w') as f:
|
|
||||||
json.dump(archive_data, f, indent=2)
|
|
||||||
|
|
||||||
# Mark change as archived
|
|
||||||
change.archived = True
|
|
||||||
change.save()
|
|
||||||
|
|
||||||
def run_maintenance(self) -> Dict[str, int]:
|
|
||||||
"""Run all cleanup operations"""
|
|
||||||
results = {
|
|
||||||
'archived_changes': self.archive_old_changes(),
|
|
||||||
'cleaned_branches': self.cleanup_merged_branches(),
|
|
||||||
'deactivated_branches': self.cleanup_inactive_branches(),
|
|
||||||
'cleaned_orphans': self.cleanup_orphaned_changes()
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Version control maintenance completed", extra=results)
|
|
||||||
return results
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
from django.core.cache import cache
|
|
||||||
from django.db import models
|
|
||||||
from django.utils import timezone
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
|
||||||
from .models import VersionTag, ChangeSet
|
|
||||||
|
|
||||||
class StructuredDiff:
|
|
||||||
def __init__(self, version1: str, version2: str):
|
|
||||||
self.version1 = version1
|
|
||||||
self.version2 = version2
|
|
||||||
self.changes: List[Dict[str, Any]] = []
|
|
||||||
self.impact_score = 0.0
|
|
||||||
self.computation_time = 0.0
|
|
||||||
self.timestamp = timezone.now()
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
|
||||||
return {
|
|
||||||
'version1': self.version1,
|
|
||||||
'version2': self.version2,
|
|
||||||
'changes': self.changes,
|
|
||||||
'impact_score': self.impact_score,
|
|
||||||
'computation_time': self.computation_time,
|
|
||||||
'timestamp': self.timestamp.isoformat()
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> 'StructuredDiff':
|
|
||||||
diff = cls(data['version1'], data['version2'])
|
|
||||||
diff.changes = data['changes']
|
|
||||||
diff.impact_score = data['impact_score']
|
|
||||||
diff.computation_time = data['computation_time']
|
|
||||||
diff.timestamp = timezone.datetime.fromisoformat(data['timestamp'])
|
|
||||||
return diff
|
|
||||||
|
|
||||||
class ComparisonEngine:
|
|
||||||
"""Handles version comparison operations with background processing and caching"""
|
|
||||||
|
|
||||||
def __init__(self, chunk_size: int = 10485760): # 10MB default chunk size
|
|
||||||
self.chunk_size = chunk_size
|
|
||||||
self.executor = ThreadPoolExecutor(max_workers=4)
|
|
||||||
self.cache_ttl = 300 # 5 minutes cache TTL
|
|
||||||
|
|
||||||
async def compare_versions(self, versions: List[str]) -> List[StructuredDiff]:
|
|
||||||
"""
|
|
||||||
Compare multiple versions, processing in background and using cache
|
|
||||||
Args:
|
|
||||||
versions: List of version identifiers to compare
|
|
||||||
Returns:
|
|
||||||
List of StructuredDiff objects with comparison results
|
|
||||||
"""
|
|
||||||
if len(versions) < 2:
|
|
||||||
raise ValueError("At least two versions required for comparison")
|
|
||||||
|
|
||||||
results: List[StructuredDiff] = []
|
|
||||||
cache_misses = []
|
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
for i in range(len(versions) - 1):
|
|
||||||
for j in range(i + 1, len(versions)):
|
|
||||||
cache_key = self._get_cache_key(versions[i], versions[j])
|
|
||||||
cached_result = cache.get(cache_key)
|
|
||||||
|
|
||||||
if cached_result:
|
|
||||||
results.append(StructuredDiff.from_dict(json.loads(cached_result)))
|
|
||||||
else:
|
|
||||||
cache_misses.append((versions[i], versions[j]))
|
|
||||||
|
|
||||||
# Process cache misses in background
|
|
||||||
if cache_misses:
|
|
||||||
comparison_tasks = [
|
|
||||||
self._compare_version_pair(v1, v2)
|
|
||||||
for v1, v2 in cache_misses
|
|
||||||
]
|
|
||||||
new_results = await asyncio.gather(*comparison_tasks)
|
|
||||||
results.extend(new_results)
|
|
||||||
|
|
||||||
return sorted(
|
|
||||||
results,
|
|
||||||
key=lambda x: (x.version1, x.version2)
|
|
||||||
)
|
|
||||||
|
|
||||||
def calculate_impact_score(self, diffs: List[StructuredDiff]) -> float:
|
|
||||||
"""
|
|
||||||
Calculate impact score for a set of diffs
|
|
||||||
Args:
|
|
||||||
diffs: List of StructuredDiff objects
|
|
||||||
Returns:
|
|
||||||
Float impact score (0-1)
|
|
||||||
"""
|
|
||||||
if not diffs:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
total_score = 0.0
|
|
||||||
weights = {
|
|
||||||
'file_count': 0.3,
|
|
||||||
'change_size': 0.3,
|
|
||||||
'structural_impact': 0.4
|
|
||||||
}
|
|
||||||
|
|
||||||
for diff in diffs:
|
|
||||||
# File count impact
|
|
||||||
file_count = len(set(c['file'] for c in diff.changes))
|
|
||||||
file_score = min(file_count / 100, 1.0) # Normalize to max 100 files
|
|
||||||
|
|
||||||
# Change size impact
|
|
||||||
total_changes = sum(
|
|
||||||
len(c.get('additions', [])) + len(c.get('deletions', []))
|
|
||||||
for c in diff.changes
|
|
||||||
)
|
|
||||||
size_score = min(total_changes / 1000, 1.0) # Normalize to max 1000 lines
|
|
||||||
|
|
||||||
# Structural impact (e.g., function/class changes)
|
|
||||||
structural_changes = sum(
|
|
||||||
1 for c in diff.changes
|
|
||||||
if c.get('type') in ['function', 'class', 'schema']
|
|
||||||
)
|
|
||||||
structural_score = min(structural_changes / 10, 1.0) # Normalize to max 10 structural changes
|
|
||||||
|
|
||||||
# Weighted average
|
|
||||||
diff.impact_score = (
|
|
||||||
weights['file_count'] * file_score +
|
|
||||||
weights['change_size'] * size_score +
|
|
||||||
weights['structural_impact'] * structural_score
|
|
||||||
)
|
|
||||||
total_score += diff.impact_score
|
|
||||||
|
|
||||||
return total_score / len(diffs)
|
|
||||||
|
|
||||||
async def _compare_version_pair(self, version1: str, version2: str) -> StructuredDiff:
|
|
||||||
"""Compare two versions in background"""
|
|
||||||
start_time = timezone.now()
|
|
||||||
|
|
||||||
# Create diff structure
|
|
||||||
diff = StructuredDiff(version1, version2)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get version data
|
|
||||||
v1_tag = await self._get_version_tag(version1)
|
|
||||||
v2_tag = await self._get_version_tag(version2)
|
|
||||||
|
|
||||||
if not v1_tag or not v2_tag:
|
|
||||||
raise ValueError("Version tag not found")
|
|
||||||
|
|
||||||
# Process in chunks if needed
|
|
||||||
changes = await self._process_version_changes(v1_tag, v2_tag)
|
|
||||||
diff.changes = changes
|
|
||||||
|
|
||||||
# Calculate impact score
|
|
||||||
diff.impact_score = self.calculate_impact_score([diff])
|
|
||||||
|
|
||||||
# Store in cache
|
|
||||||
cache_key = self._get_cache_key(version1, version2)
|
|
||||||
cache.set(
|
|
||||||
cache_key,
|
|
||||||
json.dumps(diff.to_dict()),
|
|
||||||
self.cache_ttl
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
diff.changes = [{'error': str(e)}]
|
|
||||||
diff.impact_score = 0.0
|
|
||||||
|
|
||||||
diff.computation_time = (timezone.now() - start_time).total_seconds()
|
|
||||||
return diff
|
|
||||||
|
|
||||||
async def _get_version_tag(self, version: str) -> Optional[VersionTag]:
|
|
||||||
"""Get version tag by identifier"""
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
return await loop.run_in_executor(
|
|
||||||
self.executor,
|
|
||||||
lambda: VersionTag.objects.filter(name=version).first()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _process_version_changes(
|
|
||||||
self,
|
|
||||||
v1_tag: VersionTag,
|
|
||||||
v2_tag: VersionTag
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Process changes between versions in chunks"""
|
|
||||||
changes = []
|
|
||||||
|
|
||||||
# Get changesets between versions
|
|
||||||
changesets = await self._get_changesets_between(v1_tag, v2_tag)
|
|
||||||
|
|
||||||
for changeset in changesets:
|
|
||||||
# Process each change in chunks if needed
|
|
||||||
change_data = await self._process_changeset(changeset)
|
|
||||||
changes.extend(change_data)
|
|
||||||
|
|
||||||
return changes
|
|
||||||
|
|
||||||
async def _get_changesets_between(
|
|
||||||
self,
|
|
||||||
v1_tag: VersionTag,
|
|
||||||
v2_tag: VersionTag
|
|
||||||
) -> List[ChangeSet]:
|
|
||||||
"""Get all changesets between two versions"""
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
return await loop.run_in_executor(
|
|
||||||
self.executor,
|
|
||||||
lambda: list(ChangeSet.objects.filter(
|
|
||||||
branch=v2_tag.branch,
|
|
||||||
created_at__gt=v1_tag.created_at,
|
|
||||||
created_at__lte=v2_tag.created_at,
|
|
||||||
status='applied'
|
|
||||||
).order_by('created_at'))
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _process_changeset(self, changeset: ChangeSet) -> List[Dict[str, Any]]:
|
|
||||||
"""Process individual changeset for comparison"""
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
|
|
||||||
def process():
|
|
||||||
changes = []
|
|
||||||
instance = changeset.historical_instance
|
|
||||||
if instance:
|
|
||||||
# Get changes from historical record
|
|
||||||
diff = instance.diff_against_previous
|
|
||||||
if diff:
|
|
||||||
for field, values in diff.items():
|
|
||||||
change = {
|
|
||||||
'type': 'field',
|
|
||||||
'file': f"{instance._meta.model_name}.{field}",
|
|
||||||
'old_value': values['old'],
|
|
||||||
'new_value': values['new']
|
|
||||||
}
|
|
||||||
changes.append(change)
|
|
||||||
return changes
|
|
||||||
|
|
||||||
return await loop.run_in_executor(self.executor, process)
|
|
||||||
|
|
||||||
def _get_cache_key(self, version1: str, version2: str) -> str:
|
|
||||||
"""Generate cache key for version comparison"""
|
|
||||||
return f"version_diff:{version1}:{version2}"
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
from typing import Dict, Any
|
|
||||||
from django.http import HttpRequest
|
|
||||||
from .signals import get_current_branch
|
|
||||||
from .models import VersionBranch, ChangeSet
|
|
||||||
|
|
||||||
def version_control(request: HttpRequest) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Add version control information to the template context
|
|
||||||
"""
|
|
||||||
current_branch = get_current_branch()
|
|
||||||
context = {
|
|
||||||
'vcs_enabled': True,
|
|
||||||
'current_branch': current_branch,
|
|
||||||
'recent_changes': []
|
|
||||||
}
|
|
||||||
|
|
||||||
if current_branch:
|
|
||||||
# Get recent changes for the current branch
|
|
||||||
recent_changes = ChangeSet.objects.filter(
|
|
||||||
branch=current_branch,
|
|
||||||
status='applied'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
context.update({
|
|
||||||
'recent_changes': recent_changes,
|
|
||||||
'branch_name': current_branch.name,
|
|
||||||
'branch_metadata': current_branch.metadata
|
|
||||||
})
|
|
||||||
|
|
||||||
# Get available branches for switching
|
|
||||||
context['available_branches'] = VersionBranch.objects.filter(
|
|
||||||
is_active=True
|
|
||||||
).order_by('-created_at')
|
|
||||||
|
|
||||||
# Check if current page is versioned
|
|
||||||
if hasattr(request, 'resolver_match') and request.resolver_match:
|
|
||||||
view_func = request.resolver_match.func
|
|
||||||
if hasattr(view_func, 'view_class'):
|
|
||||||
view_class = view_func.view_class
|
|
||||||
context['page_is_versioned'] = hasattr(view_class, 'model') and \
|
|
||||||
hasattr(view_class.model, 'history')
|
|
||||||
|
|
||||||
return {'version_control': context}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
from simple_history.models import HistoricalRecords
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
from django.utils.timezone import now
|
|
||||||
|
|
||||||
class CustomHistoricalRecords(HistoricalRecords):
|
|
||||||
"""Custom historical records that properly handle generic relations."""
|
|
||||||
|
|
||||||
def copy_fields(self, model):
|
|
||||||
"""
|
|
||||||
Copy fields from the model to the historical record model,
|
|
||||||
excluding GenericRelation fields.
|
|
||||||
"""
|
|
||||||
fields = {}
|
|
||||||
for field in model._meta.concrete_fields:
|
|
||||||
if not isinstance(field, GenericRelation) and field.name not in [
|
|
||||||
'comments', 'comment_threads', 'photos', 'reviews'
|
|
||||||
]:
|
|
||||||
fields[field.name] = field.clone()
|
|
||||||
return fields
|
|
||||||
|
|
||||||
def create_history_model(self, model, inherited):
|
|
||||||
"""
|
|
||||||
Override to ensure we don't create duplicate auto fields.
|
|
||||||
"""
|
|
||||||
attrs = {
|
|
||||||
'__module__': model.__module__,
|
|
||||||
'_history_excluded_fields': ['comments', 'comment_threads', 'photos', 'reviews'],
|
|
||||||
}
|
|
||||||
|
|
||||||
app_module = '%s.models' % model._meta.app_label
|
|
||||||
|
|
||||||
if inherited:
|
|
||||||
# inherited use models.AutoField instead of models.IntegerField
|
|
||||||
attrs.update({
|
|
||||||
'id': models.AutoField(primary_key=True),
|
|
||||||
'history_id': models.AutoField(primary_key=True),
|
|
||||||
'history_date': models.DateTimeField(default=now),
|
|
||||||
'history_change_reason': models.CharField(max_length=100, null=True),
|
|
||||||
'history_type': models.CharField(max_length=1, choices=(
|
|
||||||
('+', 'Created'),
|
|
||||||
('~', 'Changed'),
|
|
||||||
('-', 'Deleted'),
|
|
||||||
)),
|
|
||||||
'history_user': models.ForeignKey(
|
|
||||||
'accounts.User',
|
|
||||||
null=True,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name='+'
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Convert field to point to historical model
|
|
||||||
fields = self.copy_fields(model)
|
|
||||||
attrs.update(fields)
|
|
||||||
|
|
||||||
return type(
|
|
||||||
str('Historical%s' % model._meta.object_name),
|
|
||||||
(models.Model,),
|
|
||||||
attrs
|
|
||||||
)
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey
|
|
||||||
from typing import List, Type
|
|
||||||
|
|
||||||
def get_trackable_fields(model_class: Type[models.Model]) -> List[models.Field]:
|
|
||||||
"""Get fields that should be tracked in history."""
|
|
||||||
if getattr(model_class, '_is_historical_model', False):
|
|
||||||
# For historical models, only return core history fields
|
|
||||||
return [
|
|
||||||
models.BigAutoField(name='id', primary_key=True),
|
|
||||||
models.DateTimeField(name='history_date'),
|
|
||||||
models.CharField(name='history_change_reason', max_length=100, null=True),
|
|
||||||
models.CharField(name='history_type', max_length=1),
|
|
||||||
models.ForeignKey(
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
name='history_user',
|
|
||||||
null=True,
|
|
||||||
on_delete=models.SET_NULL
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
trackable_fields = []
|
|
||||||
excluded_fields = {
|
|
||||||
'comment_threads', 'comments', 'photos', 'reviews',
|
|
||||||
'thread', 'content_type', 'object_id', 'content_object'
|
|
||||||
}
|
|
||||||
|
|
||||||
for field in model_class._meta.get_fields():
|
|
||||||
# Skip fields we don't want to track
|
|
||||||
if any([
|
|
||||||
isinstance(field, (GenericRelation, GenericForeignKey)),
|
|
||||||
field.name in excluded_fields,
|
|
||||||
field.is_relation and hasattr(field.remote_field.model, '_meta') and
|
|
||||||
'commentthread' in field.remote_field.model._meta.model_name.lower()
|
|
||||||
]):
|
|
||||||
continue
|
|
||||||
|
|
||||||
trackable_fields.append(field)
|
|
||||||
|
|
||||||
return trackable_fields
|
|
||||||
|
|
||||||
class HistoricalFieldsMixin:
|
|
||||||
"""Mixin that controls which fields are copied to historical models."""
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_fields_to_track(cls) -> List[models.Field]:
|
|
||||||
"""Get fields that should be tracked in history."""
|
|
||||||
return get_trackable_fields(cls)
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
from django.shortcuts import render, get_object_or_404
|
|
||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from django.views.decorators.http import require_http_methods
|
|
||||||
from django.http import HttpRequest, HttpResponse, Http404
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.core.exceptions import PermissionDenied
|
|
||||||
|
|
||||||
from .models import ChangeSet, HistoricalCommentThread, Comment
|
|
||||||
from .notifications import NotificationDispatcher
|
|
||||||
from .state_machine import ApprovalStateMachine
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def get_comments(request: HttpRequest) -> HttpResponse:
|
|
||||||
"""HTMX endpoint to get comments for a specific anchor"""
|
|
||||||
anchor = request.GET.get('anchor')
|
|
||||||
if not anchor:
|
|
||||||
raise Http404("Anchor parameter is required")
|
|
||||||
|
|
||||||
thread = HistoricalCommentThread.objects.filter(anchor__id=anchor).first()
|
|
||||||
comments = thread.comments.all() if thread else []
|
|
||||||
|
|
||||||
return render(request, 'history_tracking/partials/comments_list.html', {
|
|
||||||
'comments': comments,
|
|
||||||
'anchor': anchor
|
|
||||||
})
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(["POST"])
|
|
||||||
def preview_comment(request: HttpRequest) -> HttpResponse:
|
|
||||||
"""HTMX endpoint for live comment preview"""
|
|
||||||
content = request.POST.get('content', '')
|
|
||||||
return render(request, 'history_tracking/partials/comment_preview.html', {
|
|
||||||
'content': content
|
|
||||||
})
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(["POST"])
|
|
||||||
def add_comment(request: HttpRequest) -> HttpResponse:
|
|
||||||
"""HTMX endpoint to add a comment"""
|
|
||||||
anchor = request.POST.get('anchor')
|
|
||||||
content = request.POST.get('content')
|
|
||||||
parent_id = request.POST.get('parent_id')
|
|
||||||
|
|
||||||
if not content:
|
|
||||||
return HttpResponse("Comment content is required", status=400)
|
|
||||||
|
|
||||||
thread, created = HistoricalCommentThread.objects.get_or_create(
|
|
||||||
anchor={'id': anchor},
|
|
||||||
defaults={'created_by': request.user}
|
|
||||||
)
|
|
||||||
|
|
||||||
comment = thread.comments.create(
|
|
||||||
author=request.user,
|
|
||||||
content=content,
|
|
||||||
parent_id=parent_id if parent_id else None
|
|
||||||
)
|
|
||||||
|
|
||||||
comment.extract_mentions()
|
|
||||||
|
|
||||||
# Send notifications
|
|
||||||
dispatcher = NotificationDispatcher()
|
|
||||||
dispatcher.notify_new_comment(comment, thread)
|
|
||||||
|
|
||||||
# Return updated comments list
|
|
||||||
return render(request, 'history_tracking/partials/comments_list.html', {
|
|
||||||
'comments': thread.comments.all(),
|
|
||||||
'anchor': anchor
|
|
||||||
})
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(["POST"])
|
|
||||||
def approve_changes(request: HttpRequest, changeset_id: int) -> HttpResponse:
|
|
||||||
"""HTMX endpoint for approving/rejecting changes"""
|
|
||||||
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
|
|
||||||
state_machine = ApprovalStateMachine(changeset)
|
|
||||||
|
|
||||||
if not state_machine.can_user_approve(request.user):
|
|
||||||
raise PermissionDenied("You don't have permission to approve these changes")
|
|
||||||
|
|
||||||
decision = request.POST.get('decision', 'approve')
|
|
||||||
comment = request.POST.get('comment', '')
|
|
||||||
stage_id = request.POST.get('stage_id')
|
|
||||||
|
|
||||||
success = state_machine.submit_approval(
|
|
||||||
user=request.user,
|
|
||||||
decision=decision,
|
|
||||||
comment=comment,
|
|
||||||
stage_id=stage_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if not success:
|
|
||||||
return HttpResponse("Failed to submit approval", status=400)
|
|
||||||
|
|
||||||
# Return updated approval status
|
|
||||||
return render(request, 'history_tracking/partials/approval_status.html', {
|
|
||||||
'changeset': changeset,
|
|
||||||
'current_stage': state_machine.get_current_stage(),
|
|
||||||
'can_approve': state_machine.can_user_approve(request.user),
|
|
||||||
'pending_approvers': state_machine.get_pending_approvers()
|
|
||||||
})
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def approval_notifications(request: HttpRequest, changeset_id: int) -> HttpResponse:
|
|
||||||
"""HTMX endpoint for live approval notifications"""
|
|
||||||
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
|
|
||||||
return render(request, 'history_tracking/partials/approval_notifications.html', {
|
|
||||||
'notifications': changeset.get_recent_notifications()
|
|
||||||
})
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def get_replies(request: HttpRequest, comment_id: int) -> HttpResponse:
|
|
||||||
"""HTMX endpoint to get comment replies"""
|
|
||||||
comment = get_object_or_404(Comment, pk=comment_id)
|
|
||||||
return render(request, 'history_tracking/partials/comment_replies.html', {
|
|
||||||
'replies': comment.replies.all()
|
|
||||||
})
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def reply_form(request: HttpRequest) -> HttpResponse:
|
|
||||||
"""HTMX endpoint to get the reply form template"""
|
|
||||||
return render(request, 'history_tracking/partials/reply_form.html', {
|
|
||||||
'parent_id': request.GET.get('parent_id')
|
|
||||||
})
|
|
||||||
@@ -1,519 +0,0 @@
|
|||||||
from typing import Optional, List, Dict, Any, Tuple, Type, TypeVar, cast
|
|
||||||
from django.db import transaction
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
|
||||||
from collections import Counter
|
|
||||||
import json
|
|
||||||
from .models import VersionBranch, VersionTag, ChangeSet
|
|
||||||
|
|
||||||
UserModel = TypeVar('UserModel', bound=AbstractUser)
|
|
||||||
User = cast(Type[UserModel], get_user_model())
|
|
||||||
|
|
||||||
class BranchManager:
|
|
||||||
"""Manages version control branch operations"""
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def create_branch(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
parent: Optional[VersionBranch] = None,
|
|
||||||
user: Optional[UserModel] = None
|
|
||||||
) -> VersionBranch:
|
|
||||||
"""Create a new version branch"""
|
|
||||||
branch = VersionBranch.objects.create(
|
|
||||||
name=name,
|
|
||||||
parent=parent,
|
|
||||||
created_by=user,
|
|
||||||
metadata={
|
|
||||||
'created_from': parent.name if parent else 'root',
|
|
||||||
'created_at': timezone.now().isoformat()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
branch.full_clean()
|
|
||||||
return branch
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def merge_branches(
|
|
||||||
self,
|
|
||||||
source: VersionBranch,
|
|
||||||
target: VersionBranch,
|
|
||||||
user: Optional[UserModel] = None
|
|
||||||
) -> Tuple[bool, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
Merge source branch into target branch
|
|
||||||
Returns: (success, conflicts)
|
|
||||||
"""
|
|
||||||
if not source.is_active or not target.is_active:
|
|
||||||
raise ValidationError("Cannot merge inactive branches")
|
|
||||||
|
|
||||||
merger = MergeStrategy()
|
|
||||||
success, conflicts = merger.auto_merge(source, target)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
# Record successful merge
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
branch=target,
|
|
||||||
created_by=user,
|
|
||||||
description=f"Merged branch '{source.name}' into '{target.name}'",
|
|
||||||
metadata={
|
|
||||||
'merge_source': source.name,
|
|
||||||
'merge_target': target.name,
|
|
||||||
'merged_at': timezone.now().isoformat()
|
|
||||||
},
|
|
||||||
status='applied'
|
|
||||||
)
|
|
||||||
|
|
||||||
return success, conflicts
|
|
||||||
|
|
||||||
def list_branches(self, include_inactive: bool = False) -> List[VersionBranch]:
|
|
||||||
"""Get all branches with their relationships"""
|
|
||||||
queryset = VersionBranch.objects.select_related('parent')
|
|
||||||
if not include_inactive:
|
|
||||||
queryset = queryset.filter(is_active=True)
|
|
||||||
return list(queryset)
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def acquire_lock(
|
|
||||||
self,
|
|
||||||
branch: VersionBranch,
|
|
||||||
user: UserModel,
|
|
||||||
duration: int = 48,
|
|
||||||
reason: str = ""
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Acquire a lock on a branch
|
|
||||||
Args:
|
|
||||||
branch: The branch to lock
|
|
||||||
user: User acquiring the lock
|
|
||||||
duration: Lock duration in hours (default 48)
|
|
||||||
reason: Reason for locking
|
|
||||||
Returns:
|
|
||||||
bool: True if lock acquired, False if already locked
|
|
||||||
"""
|
|
||||||
# Check if branch is already locked
|
|
||||||
if branch.lock_status:
|
|
||||||
expires = timezone.datetime.fromisoformat(branch.lock_status['expires'])
|
|
||||||
if timezone.now() < expires:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Set lock
|
|
||||||
expiry = timezone.now() + timezone.timedelta(hours=duration)
|
|
||||||
branch.lock_status = {
|
|
||||||
'user': user.id,
|
|
||||||
'expires': expiry.isoformat(),
|
|
||||||
'reason': reason
|
|
||||||
}
|
|
||||||
|
|
||||||
# Record in history
|
|
||||||
branch.lock_history.append({
|
|
||||||
'user': user.id,
|
|
||||||
'action': 'lock',
|
|
||||||
'timestamp': timezone.now().isoformat(),
|
|
||||||
'reason': reason,
|
|
||||||
'expires': expiry.isoformat()
|
|
||||||
})
|
|
||||||
|
|
||||||
branch.save()
|
|
||||||
return True
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def release_lock(
|
|
||||||
self,
|
|
||||||
branch: VersionBranch,
|
|
||||||
user: UserModel,
|
|
||||||
force: bool = False
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Release a lock on a branch
|
|
||||||
Args:
|
|
||||||
branch: The branch to unlock
|
|
||||||
user: User releasing the lock
|
|
||||||
force: Whether to force unlock (requires permissions)
|
|
||||||
Returns:
|
|
||||||
bool: True if lock released, False if not locked or unauthorized
|
|
||||||
"""
|
|
||||||
if not branch.lock_status:
|
|
||||||
return False
|
|
||||||
|
|
||||||
locked_by = branch.lock_status.get('user')
|
|
||||||
if not locked_by:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check authorization
|
|
||||||
if not force and locked_by != user.id:
|
|
||||||
if not user.has_perm('history_tracking.force_unlock_branch'):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Record in history
|
|
||||||
branch.lock_history.append({
|
|
||||||
'user': user.id,
|
|
||||||
'action': 'unlock',
|
|
||||||
'timestamp': timezone.now().isoformat(),
|
|
||||||
'forced': force
|
|
||||||
})
|
|
||||||
|
|
||||||
# Clear lock
|
|
||||||
branch.lock_status = {}
|
|
||||||
branch.save()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def check_lock(self, branch: VersionBranch) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Check the lock status of a branch
|
|
||||||
Args:
|
|
||||||
branch: The branch to check
|
|
||||||
Returns:
|
|
||||||
dict: Lock status information
|
|
||||||
"""
|
|
||||||
if not branch.lock_status:
|
|
||||||
return {'locked': False}
|
|
||||||
|
|
||||||
expires = timezone.datetime.fromisoformat(branch.lock_status['expires'])
|
|
||||||
if timezone.now() >= expires:
|
|
||||||
# Lock has expired
|
|
||||||
branch.lock_status = {}
|
|
||||||
branch.save()
|
|
||||||
return {'locked': False}
|
|
||||||
|
|
||||||
return {
|
|
||||||
'locked': True,
|
|
||||||
'user': User.objects.get(id=branch.lock_status['user']),
|
|
||||||
'expires': expires,
|
|
||||||
'reason': branch.lock_status.get('reason', '')
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_lock_history(
|
|
||||||
self,
|
|
||||||
branch: VersionBranch,
|
|
||||||
limit: Optional[int] = None
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get the lock history for a branch
|
|
||||||
Args:
|
|
||||||
branch: The branch to get history for
|
|
||||||
limit: Optional limit on number of entries
|
|
||||||
Returns:
|
|
||||||
list: Lock history entries
|
|
||||||
"""
|
|
||||||
history = branch.lock_history
|
|
||||||
if limit:
|
|
||||||
history = history[-limit:]
|
|
||||||
|
|
||||||
# Enhance history with user objects
|
|
||||||
for entry in history:
|
|
||||||
try:
|
|
||||||
entry['user_obj'] = User.objects.get(id=entry['user'])
|
|
||||||
except User.DoesNotExist:
|
|
||||||
entry['user_obj'] = None
|
|
||||||
|
|
||||||
return history
|
|
||||||
|
|
||||||
class ChangeTracker:
|
|
||||||
"""Tracks and manages changes across the system"""
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def record_change(
|
|
||||||
self,
|
|
||||||
instance: Any,
|
|
||||||
change_type: str,
|
|
||||||
branch: VersionBranch,
|
|
||||||
user: Optional[UserModel] = None,
|
|
||||||
metadata: Optional[Dict] = None
|
|
||||||
) -> ChangeSet:
|
|
||||||
"""Record a change in the system"""
|
|
||||||
if not hasattr(instance, 'history'):
|
|
||||||
raise ValueError("Instance must be a model with history tracking enabled")
|
|
||||||
|
|
||||||
# Create historical record by saving the instance
|
|
||||||
instance.save()
|
|
||||||
historical_record = instance.history.first()
|
|
||||||
|
|
||||||
if not historical_record:
|
|
||||||
raise ValueError("Failed to create historical record")
|
|
||||||
|
|
||||||
# Create changeset
|
|
||||||
content_type = ContentType.objects.get_for_model(historical_record)
|
|
||||||
changeset = ChangeSet.objects.create(
|
|
||||||
branch=branch,
|
|
||||||
created_by=user,
|
|
||||||
description=f"{change_type} operation on {instance._meta.model_name}",
|
|
||||||
metadata=metadata or {},
|
|
||||||
status='pending',
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=historical_record.pk
|
|
||||||
)
|
|
||||||
|
|
||||||
return changeset
|
|
||||||
|
|
||||||
def get_changes(self, branch: VersionBranch) -> List[ChangeSet]:
|
|
||||||
"""Get all changes in a branch ordered by creation time"""
|
|
||||||
return list(ChangeSet.objects.filter(branch=branch).order_by('created_at'))
|
|
||||||
|
|
||||||
def compute_enhanced_diff(
|
|
||||||
self,
|
|
||||||
version1: Any,
|
|
||||||
version2: Any,
|
|
||||||
syntax_detect: bool = True
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Return structured diff with syntax metadata
|
|
||||||
Args:
|
|
||||||
version1: First version to compare
|
|
||||||
version2: Second version to compare
|
|
||||||
syntax_detect: Whether to detect syntax types
|
|
||||||
Returns:
|
|
||||||
Dict containing structured diff with metadata
|
|
||||||
"""
|
|
||||||
if not hasattr(version1, 'history') or not hasattr(version2, 'history'):
|
|
||||||
raise ValueError("Both versions must be history-tracked models")
|
|
||||||
|
|
||||||
# Get historical records
|
|
||||||
v1_history = version1.history.first()
|
|
||||||
v2_history = version2.history.first()
|
|
||||||
|
|
||||||
if not (v1_history and v2_history):
|
|
||||||
raise ValueError("No historical records found")
|
|
||||||
|
|
||||||
changes = {}
|
|
||||||
|
|
||||||
# Compare fields and detect syntax
|
|
||||||
for field in v2_history._meta.fields:
|
|
||||||
field_name = field.name
|
|
||||||
if field_name in [
|
|
||||||
'history_id', 'history_date', 'history_type',
|
|
||||||
'history_user_id', 'history_change_reason'
|
|
||||||
]:
|
|
||||||
continue
|
|
||||||
|
|
||||||
old_value = getattr(v1_history, field_name)
|
|
||||||
new_value = getattr(v2_history, field_name)
|
|
||||||
|
|
||||||
if old_value != new_value:
|
|
||||||
field_type = field.get_internal_type()
|
|
||||||
syntax_type = self._detect_syntax(field_type, old_value) if syntax_detect else 'text'
|
|
||||||
|
|
||||||
changes[field_name] = {
|
|
||||||
'old': str(old_value),
|
|
||||||
'new': str(new_value),
|
|
||||||
'type': field_type,
|
|
||||||
'syntax': syntax_type,
|
|
||||||
'line_numbers': self._compute_line_numbers(old_value, new_value),
|
|
||||||
'metadata': {
|
|
||||||
'comment_anchor_id': f"{v2_history.history_id}_{field_name}",
|
|
||||||
'field_type': field_type,
|
|
||||||
'content_type': v2_history._meta.model_name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Calculate impact metrics
|
|
||||||
impact_metrics = self._calculate_impact_metrics(changes)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'changes': changes,
|
|
||||||
'metadata': {
|
|
||||||
'version1_id': v1_history.history_id,
|
|
||||||
'version2_id': v2_history.history_id,
|
|
||||||
'timestamp': timezone.now().isoformat(),
|
|
||||||
'impact_score': impact_metrics['impact_score'],
|
|
||||||
'stats': impact_metrics['stats'],
|
|
||||||
'performance': {
|
|
||||||
'syntax_detection': syntax_detect,
|
|
||||||
'computed_at': timezone.now().isoformat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def _detect_syntax(self, field_type: str, value: Any) -> str:
|
|
||||||
"""
|
|
||||||
Detect syntax type for field content
|
|
||||||
Args:
|
|
||||||
field_type: Django field type
|
|
||||||
value: Field value
|
|
||||||
Returns:
|
|
||||||
Detected syntax type
|
|
||||||
"""
|
|
||||||
if field_type in ['TextField', 'CharField']:
|
|
||||||
# Try to detect if it's code
|
|
||||||
if isinstance(value, str):
|
|
||||||
if value.startswith('def ') or value.startswith('class '):
|
|
||||||
return 'python'
|
|
||||||
if value.startswith('{') or value.startswith('['):
|
|
||||||
try:
|
|
||||||
json.loads(value)
|
|
||||||
return 'json'
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
if value.startswith('<!DOCTYPE') or value.startswith('<html'):
|
|
||||||
return 'html'
|
|
||||||
if value.startswith('// ') or value.startswith('function '):
|
|
||||||
return 'javascript'
|
|
||||||
|
|
||||||
syntax_map = {
|
|
||||||
'JSONField': 'json',
|
|
||||||
'FileField': 'path',
|
|
||||||
'FilePathField': 'path',
|
|
||||||
'URLField': 'url',
|
|
||||||
'EmailField': 'email',
|
|
||||||
'TextField': 'text',
|
|
||||||
'CharField': 'text'
|
|
||||||
}
|
|
||||||
return syntax_map.get(field_type, 'text')
|
|
||||||
|
|
||||||
def _compute_line_numbers(self, old_value: Any, new_value: Any) -> Dict[str, List[int]]:
|
|
||||||
"""
|
|
||||||
Compute line numbers for diff navigation
|
|
||||||
Args:
|
|
||||||
old_value: Previous value
|
|
||||||
new_value: New value
|
|
||||||
Returns:
|
|
||||||
Dict with old and new line numbers
|
|
||||||
"""
|
|
||||||
def count_lines(value):
|
|
||||||
if not isinstance(value, str):
|
|
||||||
value = str(value)
|
|
||||||
return value.count('\n') + 1
|
|
||||||
|
|
||||||
old_lines = count_lines(old_value)
|
|
||||||
new_lines = count_lines(new_value)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'old': list(range(1, old_lines + 1)),
|
|
||||||
'new': list(range(1, new_lines + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
def _calculate_impact_metrics(self, changes: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Calculate impact metrics for changes
|
|
||||||
Args:
|
|
||||||
changes: Dict of changes
|
|
||||||
Returns:
|
|
||||||
Dict with impact metrics
|
|
||||||
"""
|
|
||||||
total_lines_changed = sum(
|
|
||||||
len(c['line_numbers']['old']) + len(c['line_numbers']['new'])
|
|
||||||
for c in changes.values()
|
|
||||||
)
|
|
||||||
|
|
||||||
field_types = Counter(c['type'] for c in changes.values())
|
|
||||||
syntax_types = Counter(c['syntax'] for c in changes.values())
|
|
||||||
|
|
||||||
# Calculate impact score (0-1)
|
|
||||||
impact_weights = {
|
|
||||||
'lines_changed': 0.4,
|
|
||||||
'fields_changed': 0.3,
|
|
||||||
'complexity': 0.3
|
|
||||||
}
|
|
||||||
|
|
||||||
# Normalize metrics
|
|
||||||
normalized_lines = min(1.0, total_lines_changed / 1000) # Cap at 1000 lines
|
|
||||||
normalized_fields = min(1.0, len(changes) / 20) # Cap at 20 fields
|
|
||||||
|
|
||||||
# Complexity based on field and syntax types
|
|
||||||
complexity_score = (
|
|
||||||
len(field_types) / 10 + # Variety of field types
|
|
||||||
len(syntax_types) / 5 + # Variety of syntax types
|
|
||||||
(field_types.get('JSONField', 0) * 0.2) + # Weight complex fields higher
|
|
||||||
(syntax_types.get('python', 0) * 0.2) # Weight code changes higher
|
|
||||||
) / 2 # Normalize to 0-1
|
|
||||||
|
|
||||||
impact_score = (
|
|
||||||
impact_weights['lines_changed'] * normalized_lines +
|
|
||||||
impact_weights['fields_changed'] * normalized_fields +
|
|
||||||
impact_weights['complexity'] * complexity_score
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'impact_score': impact_score,
|
|
||||||
'stats': {
|
|
||||||
'total_lines_changed': total_lines_changed,
|
|
||||||
'fields_changed': len(changes),
|
|
||||||
'field_types': dict(field_types),
|
|
||||||
'syntax_types': dict(syntax_types)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class MergeStrategy:
|
|
||||||
"""Handles merge operations and conflict resolution"""
|
|
||||||
|
|
||||||
def auto_merge(
|
|
||||||
self,
|
|
||||||
source: VersionBranch,
|
|
||||||
target: VersionBranch
|
|
||||||
) -> Tuple[bool, List[Dict[str, Any]]]:
|
|
||||||
"""
|
|
||||||
Attempt automatic merge between branches
|
|
||||||
Returns: (success, conflicts)
|
|
||||||
"""
|
|
||||||
conflicts = []
|
|
||||||
|
|
||||||
# Get all changes since branch creation
|
|
||||||
source_changes = ChangeSet.objects.filter(
|
|
||||||
branch=source,
|
|
||||||
status='applied'
|
|
||||||
).order_by('created_at')
|
|
||||||
|
|
||||||
target_changes = ChangeSet.objects.filter(
|
|
||||||
branch=target,
|
|
||||||
status='applied'
|
|
||||||
).order_by('created_at')
|
|
||||||
|
|
||||||
# Detect conflicts
|
|
||||||
for source_change in source_changes:
|
|
||||||
for target_change in target_changes:
|
|
||||||
if self._detect_conflict(source_change, target_change):
|
|
||||||
conflicts.append({
|
|
||||||
'source_change': source_change.pk,
|
|
||||||
'target_change': target_change.pk,
|
|
||||||
'type': 'content_conflict',
|
|
||||||
'description': 'Conflicting changes detected'
|
|
||||||
})
|
|
||||||
|
|
||||||
if conflicts:
|
|
||||||
return False, conflicts
|
|
||||||
|
|
||||||
# No conflicts, apply source changes to target
|
|
||||||
for change in source_changes:
|
|
||||||
self._apply_change_to_branch(change, target)
|
|
||||||
|
|
||||||
return True, []
|
|
||||||
|
|
||||||
def _detect_conflict(self, change1: ChangeSet, change2: ChangeSet) -> bool:
|
|
||||||
"""Check if two changes conflict with each other"""
|
|
||||||
# Get historical instances
|
|
||||||
instance1 = change1.historical_instance
|
|
||||||
instance2 = change2.historical_instance
|
|
||||||
|
|
||||||
if not (instance1 and instance2):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Same model and instance ID indicates potential conflict
|
|
||||||
return (
|
|
||||||
instance1._meta.model == instance2._meta.model and
|
|
||||||
instance1.id == instance2.id
|
|
||||||
)
|
|
||||||
|
|
||||||
@transaction.atomic
|
|
||||||
def _apply_change_to_branch(
|
|
||||||
self,
|
|
||||||
change: ChangeSet,
|
|
||||||
target_branch: VersionBranch
|
|
||||||
) -> None:
|
|
||||||
"""Apply a change from one branch to another"""
|
|
||||||
# Create new changeset in target branch
|
|
||||||
new_changeset = ChangeSet.objects.create(
|
|
||||||
branch=target_branch,
|
|
||||||
description=f"Applied change from '{change.branch.name}'",
|
|
||||||
metadata={
|
|
||||||
'source_change': change.pk,
|
|
||||||
'source_branch': change.branch.name
|
|
||||||
},
|
|
||||||
status='pending',
|
|
||||||
content_type=change.content_type,
|
|
||||||
object_id=change.object_id
|
|
||||||
)
|
|
||||||
|
|
||||||
new_changeset.status = 'applied'
|
|
||||||
new_changeset.save()
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
# Generated by Django 5.1.6 on 2025-02-06 22:00
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
|
||||||
("history_tracking", "0001_initial"),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="VersionBranch",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=255, unique=True)),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("metadata", models.JSONField(blank=True, default=dict)),
|
|
||||||
("is_active", models.BooleanField(default=True)),
|
|
||||||
(
|
|
||||||
"created_by",
|
|
||||||
models.ForeignKey(
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"parent",
|
|
||||||
models.ForeignKey(
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="children",
|
|
||||||
to="history_tracking.versionbranch",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"ordering": ["-created_at"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="ChangeSet",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("description", models.TextField(blank=True)),
|
|
||||||
("metadata", models.JSONField(blank=True, default=dict)),
|
|
||||||
("dependencies", models.JSONField(blank=True, default=dict)),
|
|
||||||
(
|
|
||||||
"status",
|
|
||||||
models.CharField(
|
|
||||||
choices=[
|
|
||||||
("pending", "Pending"),
|
|
||||||
("applied", "Applied"),
|
|
||||||
("failed", "Failed"),
|
|
||||||
("reverted", "Reverted"),
|
|
||||||
],
|
|
||||||
default="pending",
|
|
||||||
max_length=20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("object_id", models.PositiveIntegerField()),
|
|
||||||
(
|
|
||||||
"content_type",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
to="contenttypes.contenttype",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created_by",
|
|
||||||
models.ForeignKey(
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"branch",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="changesets",
|
|
||||||
to="history_tracking.versionbranch",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"ordering": ["-created_at"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="VersionTag",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=255, unique=True)),
|
|
||||||
("object_id", models.PositiveIntegerField()),
|
|
||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
|
||||||
("metadata", models.JSONField(blank=True, default=dict)),
|
|
||||||
(
|
|
||||||
"branch",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="tags",
|
|
||||||
to="history_tracking.versionbranch",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"content_type",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
to="contenttypes.contenttype",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"created_by",
|
|
||||||
models.ForeignKey(
|
|
||||||
null=True,
|
|
||||||
on_delete=django.db.models.deletion.SET_NULL,
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"ordering": ["-created_at"],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="versionbranch",
|
|
||||||
index=models.Index(fields=["name"], name="history_tra_name_cf8692_idx"),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="versionbranch",
|
|
||||||
index=models.Index(
|
|
||||||
fields=["parent"], name="history_tra_parent__c645fa_idx"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="versionbranch",
|
|
||||||
index=models.Index(
|
|
||||||
fields=["created_at"], name="history_tra_created_6f9fc9_idx"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="changeset",
|
|
||||||
index=models.Index(
|
|
||||||
fields=["branch"], name="history_tra_branch__0c1728_idx"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="changeset",
|
|
||||||
index=models.Index(
|
|
||||||
fields=["created_at"], name="history_tra_created_c0fe58_idx"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="changeset",
|
|
||||||
index=models.Index(fields=["status"], name="history_tra_status_93e04d_idx"),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="changeset",
|
|
||||||
index=models.Index(
|
|
||||||
fields=["content_type", "object_id"],
|
|
||||||
name="history_tra_content_9f97ff_idx",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="versiontag",
|
|
||||||
index=models.Index(fields=["name"], name="history_tra_name_38da60_idx"),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="versiontag",
|
|
||||||
index=models.Index(
|
|
||||||
fields=["branch"], name="history_tra_branch__0a9a55_idx"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="versiontag",
|
|
||||||
index=models.Index(
|
|
||||||
fields=["created_at"], name="history_tra_created_7a1501_idx"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="versiontag",
|
|
||||||
index=models.Index(
|
|
||||||
fields=["content_type", "object_id"],
|
|
||||||
name="history_tra_content_0892f3_idx",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
# history_tracking/mixins.py
|
# history_tracking/mixins.py
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
|
|
||||||
class HistoricalChangeMixin(models.Model):
|
class HistoricalChangeMixin(models.Model):
|
||||||
"""Mixin for historical models to track changes"""
|
"""Mixin for historical models to track changes"""
|
||||||
comments = GenericRelation('CommentThread', related_query_name='historical_record')
|
|
||||||
id = models.BigIntegerField(db_index=True, auto_created=True, blank=True)
|
id = models.BigIntegerField(db_index=True, auto_created=True, blank=True)
|
||||||
history_date = models.DateTimeField()
|
history_date = models.DateTimeField()
|
||||||
history_id = models.AutoField(primary_key=True)
|
history_id = models.AutoField(primary_key=True)
|
||||||
@@ -35,7 +33,6 @@ class HistoricalChangeMixin(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def diff_against_previous(self):
|
def diff_against_previous(self):
|
||||||
"""Get enhanced diff with syntax highlighting and metadata"""
|
|
||||||
prev_record = self.prev_record
|
prev_record = self.prev_record
|
||||||
if not prev_record:
|
if not prev_record:
|
||||||
return {}
|
return {}
|
||||||
@@ -57,69 +54,11 @@ class HistoricalChangeMixin(models.Model):
|
|||||||
old_value = getattr(prev_record, field)
|
old_value = getattr(prev_record, field)
|
||||||
new_value = getattr(self, field)
|
new_value = getattr(self, field)
|
||||||
if old_value != new_value:
|
if old_value != new_value:
|
||||||
field_type = self._meta.get_field(field).get_internal_type()
|
changes[field] = {"old": str(old_value), "new": str(new_value)}
|
||||||
syntax_type = self._get_syntax_type(field_type)
|
|
||||||
changes[field] = {
|
|
||||||
"old": str(old_value),
|
|
||||||
"new": str(new_value),
|
|
||||||
"syntax_type": syntax_type,
|
|
||||||
"metadata": {
|
|
||||||
"field_type": field_type,
|
|
||||||
"comment_anchor_id": f"{self.history_id}_{field}",
|
|
||||||
"line_numbers": self._compute_line_numbers(old_value, new_value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
continue
|
continue
|
||||||
return changes
|
return changes
|
||||||
|
|
||||||
def _get_syntax_type(self, field_type):
|
|
||||||
"""Map Django field types to syntax highlighting types"""
|
|
||||||
syntax_map = {
|
|
||||||
'TextField': 'text',
|
|
||||||
'JSONField': 'json',
|
|
||||||
'FileField': 'path',
|
|
||||||
'ImageField': 'path',
|
|
||||||
'URLField': 'url',
|
|
||||||
'EmailField': 'email',
|
|
||||||
'CodeField': 'python' # Custom field type for code
|
|
||||||
}
|
|
||||||
return syntax_map.get(field_type, 'text')
|
|
||||||
|
|
||||||
def _compute_line_numbers(self, old_value, new_value):
|
|
||||||
"""Compute line numbers for diff navigation"""
|
|
||||||
old_lines = str(old_value).count('\n') + 1
|
|
||||||
new_lines = str(new_value).count('\n') + 1
|
|
||||||
return {
|
|
||||||
"old": list(range(1, old_lines + 1)),
|
|
||||||
"new": list(range(1, new_lines + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_structured_diff(self, other_version=None):
|
|
||||||
"""Get structured diff between two versions with enhanced metadata"""
|
|
||||||
compare_to = other_version or self.prev_record
|
|
||||||
if not compare_to:
|
|
||||||
return None
|
|
||||||
|
|
||||||
diff_data = self.diff_against_previous
|
|
||||||
return {
|
|
||||||
"changes": diff_data,
|
|
||||||
"metadata": {
|
|
||||||
"timestamp": self.history_date.isoformat(),
|
|
||||||
"user": self.history_user_display,
|
|
||||||
"change_type": self.history_type,
|
|
||||||
"reason": self.history_change_reason,
|
|
||||||
"performance": {
|
|
||||||
"computation_time": None # To be filled by frontend
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"navigation": {
|
|
||||||
"next_id": None, # To be filled by frontend
|
|
||||||
"prev_id": None, # To be filled by frontend
|
|
||||||
"current_position": None # To be filled by frontend
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def history_user_display(self):
|
def history_user_display(self):
|
||||||
"""Get a display name for the history user"""
|
"""Get a display name for the history user"""
|
||||||
|
|||||||
@@ -1,43 +1,20 @@
|
|||||||
|
# history_tracking/models.py
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.db.models.fields.related import RelatedField
|
|
||||||
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 .historical_fields import HistoricalFieldsMixin
|
from typing import Any, Type, TypeVar, cast
|
||||||
from typing import Any, Type, TypeVar, cast, Optional, List
|
|
||||||
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, HistoricalFieldsMixin):
|
|
||||||
"""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)
|
||||||
|
history: HistoricalRecords = HistoricalRecords(
|
||||||
@classmethod
|
|
||||||
def __init_subclass__(cls, **kwargs):
|
|
||||||
"""Initialize subclass with proper configuration."""
|
|
||||||
super().__init_subclass__(**kwargs)
|
|
||||||
# Mark historical models
|
|
||||||
if cls.__name__.startswith('Historical'):
|
|
||||||
cls._is_historical_model = True
|
|
||||||
# Remove any inherited generic relations
|
|
||||||
for field in list(cls._meta.private_fields):
|
|
||||||
if isinstance(field, GenericRelation):
|
|
||||||
cls._meta.private_fields.remove(field)
|
|
||||||
else:
|
|
||||||
cls._is_historical_model = False
|
|
||||||
history = HistoricalRecords(
|
|
||||||
inherit=True,
|
inherit=True,
|
||||||
bases=[HistoricalChangeMixin],
|
bases=(HistoricalChangeMixin,)
|
||||||
excluded_fields=['comments', 'comment_threads', 'photos', 'reviews'],
|
|
||||||
use_base_model_db=True # Use base model's db
|
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -70,233 +47,3 @@ 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)
|
|
||||||
lock_status = models.JSONField(
|
|
||||||
default=dict,
|
|
||||||
help_text="Current lock status: {user: ID, expires: datetime, reason: str}"
|
|
||||||
)
|
|
||||||
lock_history = models.JSONField(
|
|
||||||
default=list,
|
|
||||||
help_text="History of lock operations: [{user: ID, action: lock/unlock, timestamp: datetime, reason: str}]"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['-created_at']
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['name']),
|
|
||||||
models.Index(fields=['parent']),
|
|
||||||
models.Index(fields=['created_at']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.name} ({'active' if self.is_active else 'inactive'})"
|
|
||||||
|
|
||||||
def clean(self) -> None:
|
|
||||||
# Prevent circular references
|
|
||||||
if self.parent and self.pk:
|
|
||||||
branch = self.parent
|
|
||||||
while branch:
|
|
||||||
if branch.pk == self.pk:
|
|
||||||
raise ValidationError("Circular branch reference detected")
|
|
||||||
branch = branch.parent
|
|
||||||
|
|
||||||
class VersionTag(models.Model):
|
|
||||||
"""Tags specific versions for reference (releases, milestones, etc)"""
|
|
||||||
name = models.CharField(max_length=255, unique=True)
|
|
||||||
branch = models.ForeignKey(VersionBranch, on_delete=models.CASCADE, related_name='tags')
|
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
|
||||||
object_id = models.PositiveIntegerField()
|
|
||||||
historical_instance = GenericForeignKey('content_type', 'object_id')
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
|
||||||
comparison_metadata = models.JSONField(
|
|
||||||
default=dict,
|
|
||||||
help_text="Stores diff statistics and comparison results"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['-created_at']
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['name']),
|
|
||||||
models.Index(fields=['branch']),
|
|
||||||
models.Index(fields=['created_at']),
|
|
||||||
models.Index(fields=['content_type', 'object_id']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"{self.name} ({self.branch.name})"
|
|
||||||
|
|
||||||
class HistoricalCommentThread(models.Model):
|
|
||||||
"""Represents a thread of comments specific to historical records and version control"""
|
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
|
||||||
object_id = models.PositiveIntegerField()
|
|
||||||
content_object = GenericForeignKey('content_type', 'object_id')
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='created_threads')
|
|
||||||
anchor = models.JSONField(
|
|
||||||
default=dict,
|
|
||||||
help_text="Anchoring information: {line_start: int, line_end: int, file_path: str}"
|
|
||||||
)
|
|
||||||
is_resolved = models.BooleanField(default=False)
|
|
||||||
resolved_by = models.ForeignKey(
|
|
||||||
User,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
related_name='resolved_threads'
|
|
||||||
)
|
|
||||||
resolved_at = models.DateTimeField(null=True, blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['-created_at']
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['content_type', 'object_id']),
|
|
||||||
models.Index(fields=['created_at']),
|
|
||||||
models.Index(fields=['is_resolved']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"Comment Thread {self.pk} on {self.content_type}"
|
|
||||||
|
|
||||||
class Comment(models.Model):
|
|
||||||
"""Individual comment within a thread"""
|
|
||||||
thread = models.ForeignKey(HistoricalCommentThread, on_delete=models.CASCADE, related_name='comments')
|
|
||||||
author = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
|
||||||
content = models.TextField()
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
mentioned_users = models.ManyToManyField(
|
|
||||||
User,
|
|
||||||
related_name='mentioned_in_comments',
|
|
||||||
blank=True
|
|
||||||
)
|
|
||||||
parent_comment = models.ForeignKey(
|
|
||||||
'self',
|
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
related_name='replies'
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['created_at']
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"Comment {self.pk} by {self.author}"
|
|
||||||
|
|
||||||
def extract_mentions(self) -> None:
|
|
||||||
"""Extract @mentions from comment content and update mentioned_users"""
|
|
||||||
# Simple @username extraction - could be enhanced with regex
|
|
||||||
mentioned = [
|
|
||||||
word[1:] for word in self.content.split()
|
|
||||||
if word.startswith('@') and len(word) > 1
|
|
||||||
]
|
|
||||||
if mentioned:
|
|
||||||
users = User.objects.filter(username__in=mentioned)
|
|
||||||
self.mentioned_users.set(users)
|
|
||||||
|
|
||||||
class ChangeSet(models.Model):
|
|
||||||
"""Groups related changes together for atomic version control operations"""
|
|
||||||
branch = models.ForeignKey(VersionBranch, on_delete=models.CASCADE, related_name='changesets')
|
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
|
||||||
description = models.TextField(blank=True)
|
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
|
||||||
dependencies = models.JSONField(default=dict, blank=True)
|
|
||||||
status = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=[
|
|
||||||
('draft', 'Draft'),
|
|
||||||
('pending_approval', 'Pending Approval'),
|
|
||||||
('approved', 'Approved'),
|
|
||||||
('rejected', 'Rejected'),
|
|
||||||
('applied', 'Applied'),
|
|
||||||
('failed', 'Failed'),
|
|
||||||
('reverted', 'Reverted')
|
|
||||||
],
|
|
||||||
default='draft'
|
|
||||||
)
|
|
||||||
approval_state = models.JSONField(
|
|
||||||
default=list,
|
|
||||||
help_text="List of approval stages and their status"
|
|
||||||
)
|
|
||||||
approval_history = models.JSONField(
|
|
||||||
default=list,
|
|
||||||
help_text="History of approval actions and decisions"
|
|
||||||
)
|
|
||||||
required_approvers = models.ManyToManyField(
|
|
||||||
User,
|
|
||||||
related_name='pending_approvals',
|
|
||||||
blank=True
|
|
||||||
)
|
|
||||||
approval_policy = models.CharField(
|
|
||||||
max_length=20,
|
|
||||||
choices=[
|
|
||||||
('sequential', 'Sequential'),
|
|
||||||
('parallel', 'Parallel')
|
|
||||||
],
|
|
||||||
default='sequential'
|
|
||||||
)
|
|
||||||
approval_deadline = models.DateTimeField(
|
|
||||||
null=True,
|
|
||||||
blank=True,
|
|
||||||
help_text="Optional deadline for approvals"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Instead of directly relating to HistoricalRecord, use GenericForeignKey
|
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
|
||||||
object_id = models.PositiveIntegerField()
|
|
||||||
historical_instance = GenericForeignKey('content_type', 'object_id')
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ['-created_at']
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=['branch']),
|
|
||||||
models.Index(fields=['created_at']),
|
|
||||||
models.Index(fields=['status']),
|
|
||||||
models.Index(fields=['content_type', 'object_id']),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return f"ChangeSet {self.pk} ({self.branch.name} - {self.status})"
|
|
||||||
|
|
||||||
def apply(self) -> None:
|
|
||||||
"""Apply the changeset to the target branch"""
|
|
||||||
if self.status != 'pending':
|
|
||||||
raise ValidationError(f"Cannot apply changeset with status: {self.status}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Apply changes through the historical instance
|
|
||||||
if self.historical_instance:
|
|
||||||
instance = self.historical_instance.instance
|
|
||||||
if instance:
|
|
||||||
instance.save()
|
|
||||||
self.status = 'applied'
|
|
||||||
except Exception as e:
|
|
||||||
self.status = 'failed'
|
|
||||||
self.metadata['error'] = str(e)
|
|
||||||
self.save()
|
|
||||||
|
|
||||||
def revert(self) -> None:
|
|
||||||
"""Revert the changes in this changeset"""
|
|
||||||
if self.status != 'applied':
|
|
||||||
raise ValidationError(f"Cannot revert changeset with status: {self.status}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Revert changes through the historical instance
|
|
||||||
if self.historical_instance:
|
|
||||||
instance = self.historical_instance.instance
|
|
||||||
if instance:
|
|
||||||
instance.save()
|
|
||||||
self.status = 'reverted'
|
|
||||||
except Exception as e:
|
|
||||||
self.metadata['revert_error'] = str(e)
|
|
||||||
self.save()
|
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
import logging
|
|
||||||
import time
|
|
||||||
from functools import wraps
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import connection
|
|
||||||
|
|
||||||
# Configure logger
|
|
||||||
logger = logging.getLogger('version_control')
|
|
||||||
|
|
||||||
def track_operation_timing(operation_name):
|
|
||||||
"""Decorator to track timing of version control operations"""
|
|
||||||
def decorator(func):
|
|
||||||
@wraps(func)
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
start_time = time.time()
|
|
||||||
try:
|
|
||||||
result = func(*args, **kwargs)
|
|
||||||
duration = time.time() - start_time
|
|
||||||
|
|
||||||
# Log timing metrics
|
|
||||||
logger.info(
|
|
||||||
'Version Control Operation Timing',
|
|
||||||
extra={
|
|
||||||
'operation': operation_name,
|
|
||||||
'duration': duration,
|
|
||||||
'success': True
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
duration = time.time() - start_time
|
|
||||||
logger.error(
|
|
||||||
'Version Control Operation Failed',
|
|
||||||
extra={
|
|
||||||
'operation': operation_name,
|
|
||||||
'duration': duration,
|
|
||||||
'error': str(e),
|
|
||||||
'success': False
|
|
||||||
}
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
return wrapper
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
def track_merge_result(source_branch, target_branch, success, conflict_count=0):
|
|
||||||
"""Track the results of merge operations"""
|
|
||||||
logger.info(
|
|
||||||
'Branch Merge Operation',
|
|
||||||
extra={
|
|
||||||
'source_branch': source_branch.name,
|
|
||||||
'target_branch': target_branch.name,
|
|
||||||
'success': success,
|
|
||||||
'conflict_count': conflict_count
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def track_branch_metrics(branch):
|
|
||||||
"""Track metrics for a specific branch"""
|
|
||||||
from history_tracking.models import ChangeSet
|
|
||||||
|
|
||||||
changes = ChangeSet.objects.filter(branch=branch)
|
|
||||||
applied_changes = changes.filter(status='applied')
|
|
||||||
pending_changes = changes.filter(status='pending')
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'Branch Metrics',
|
|
||||||
extra={
|
|
||||||
'branch_name': branch.name,
|
|
||||||
'total_changes': changes.count(),
|
|
||||||
'applied_changes': applied_changes.count(),
|
|
||||||
'pending_changes': pending_changes.count(),
|
|
||||||
'is_active': branch.is_active
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def track_database_metrics():
|
|
||||||
"""Track database metrics for version control operations"""
|
|
||||||
with connection.execute_wrapper(StatementLogger()):
|
|
||||||
yield
|
|
||||||
|
|
||||||
class StatementLogger:
|
|
||||||
"""Log database statements for monitoring"""
|
|
||||||
def __call__(self, execute, sql, params, many, context):
|
|
||||||
start = time.time()
|
|
||||||
try:
|
|
||||||
result = execute(sql, params, many, context)
|
|
||||||
duration = time.time() - start
|
|
||||||
|
|
||||||
# Log only version control related queries
|
|
||||||
if 'version' in sql.lower() or 'changeset' in sql.lower():
|
|
||||||
logger.info(
|
|
||||||
'Version Control DB Operation',
|
|
||||||
extra={
|
|
||||||
'sql': sql,
|
|
||||||
'duration': duration,
|
|
||||||
'success': True
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
duration = time.time() - start
|
|
||||||
logger.error(
|
|
||||||
'Version Control DB Operation Failed',
|
|
||||||
extra={
|
|
||||||
'sql': sql,
|
|
||||||
'duration': duration,
|
|
||||||
'error': str(e),
|
|
||||||
'success': False
|
|
||||||
}
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
class VersionControlMetrics:
|
|
||||||
"""Collect and report version control system metrics"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def collect_system_metrics():
|
|
||||||
"""Collect overall system metrics"""
|
|
||||||
from history_tracking.models import VersionBranch, ChangeSet
|
|
||||||
|
|
||||||
total_branches = VersionBranch.objects.count()
|
|
||||||
active_branches = VersionBranch.objects.filter(is_active=True).count()
|
|
||||||
total_changes = ChangeSet.objects.count()
|
|
||||||
pending_changes = ChangeSet.objects.filter(status='pending').count()
|
|
||||||
conflicted_merges = ChangeSet.objects.filter(
|
|
||||||
status='conflict'
|
|
||||||
).count()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'Version Control System Metrics',
|
|
||||||
extra={
|
|
||||||
'total_branches': total_branches,
|
|
||||||
'active_branches': active_branches,
|
|
||||||
'total_changes': total_changes,
|
|
||||||
'pending_changes': pending_changes,
|
|
||||||
'conflicted_merges': conflicted_merges
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def collect_performance_metrics():
|
|
||||||
"""Collect performance-related metrics"""
|
|
||||||
from django.db import connection
|
|
||||||
from django.core.cache import cache
|
|
||||||
|
|
||||||
# Database metrics
|
|
||||||
with connection.execute_wrapper(StatementLogger()):
|
|
||||||
db_metrics = {
|
|
||||||
'total_queries': len(connection.queries),
|
|
||||||
'total_time': sum(
|
|
||||||
float(q['time']) for q in connection.queries
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache metrics
|
|
||||||
cache_metrics = {
|
|
||||||
'hits': cache.get('version_control_cache_hits', 0),
|
|
||||||
'misses': cache.get('version_control_cache_misses', 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
'Version Control Performance Metrics',
|
|
||||||
extra={
|
|
||||||
'database': db_metrics,
|
|
||||||
'cache': cache_metrics
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def track_user_operations(user, operation, success):
|
|
||||||
"""Track user operations on version control"""
|
|
||||||
logger.info(
|
|
||||||
'Version Control User Operation',
|
|
||||||
extra={
|
|
||||||
'user_id': user.id,
|
|
||||||
'username': user.username,
|
|
||||||
'operation': operation,
|
|
||||||
'success': success
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def setup_monitoring():
|
|
||||||
"""Configure monitoring for version control system"""
|
|
||||||
if not settings.DEBUG:
|
|
||||||
# Configure logging handlers
|
|
||||||
handler = logging.handlers.RotatingFileHandler(
|
|
||||||
'logs/version_control.log',
|
|
||||||
maxBytes=10485760, # 10MB
|
|
||||||
backupCount=5
|
|
||||||
)
|
|
||||||
handler.setFormatter(logging.Formatter(
|
|
||||||
'%(asctime)s [%(levelname)s] %(message)s'
|
|
||||||
))
|
|
||||||
logger.addHandler(handler)
|
|
||||||
|
|
||||||
# Set up error reporting
|
|
||||||
import sentry_sdk # type: ignore
|
|
||||||
sentry_sdk.init(
|
|
||||||
dsn=settings.SENTRY_DSN,
|
|
||||||
traces_sample_rate=0.1,
|
|
||||||
profiles_sample_rate=0.1,
|
|
||||||
)
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
from django.core.mail import send_mail
|
|
||||||
from django.conf import settings
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
from datetime import timedelta
|
|
||||||
from celery import shared_task
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
class NotificationDispatcher:
|
|
||||||
"""Handles comment notifications and escalations"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.email_enabled = hasattr(settings, 'EMAIL_HOST')
|
|
||||||
self.slack_enabled = hasattr(settings, 'SLACK_WEBHOOK_URL')
|
|
||||||
self.sms_enabled = hasattr(settings, 'SMS_API_KEY')
|
|
||||||
|
|
||||||
def notify_new_comment(self, comment, thread):
|
|
||||||
"""Handle notification for a new comment"""
|
|
||||||
# Queue immediate notifications
|
|
||||||
self.send_in_app_notification.delay(
|
|
||||||
user_ids=self._get_thread_participants(thread),
|
|
||||||
title="New Comment",
|
|
||||||
message=f"New comment on {thread.content_object}",
|
|
||||||
link=self._get_thread_url(thread)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Queue email notifications
|
|
||||||
self.send_email_notification.delay(
|
|
||||||
user_ids=self._get_thread_participants(thread),
|
|
||||||
subject=f"New comment on {thread.content_object}",
|
|
||||||
template="notifications/new_comment.html",
|
|
||||||
context={
|
|
||||||
'comment': comment,
|
|
||||||
'thread': thread,
|
|
||||||
'url': self._get_thread_url(thread)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Schedule Slack escalation if needed
|
|
||||||
if self.slack_enabled:
|
|
||||||
self.schedule_slack_escalation.apply_async(
|
|
||||||
args=[comment.id],
|
|
||||||
countdown=24 * 3600 # 24 hours
|
|
||||||
)
|
|
||||||
|
|
||||||
def notify_mention(self, comment, mentioned_users):
|
|
||||||
"""Handle notification for @mentions"""
|
|
||||||
user_ids = [user.id for user in mentioned_users]
|
|
||||||
|
|
||||||
# Queue immediate notifications
|
|
||||||
self.send_in_app_notification.delay(
|
|
||||||
user_ids=user_ids,
|
|
||||||
title="Mentioned in Comment",
|
|
||||||
message=f"{comment.author} mentioned you in a comment",
|
|
||||||
link=self._get_comment_url(comment)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Queue email notifications
|
|
||||||
self.send_email_notification.delay(
|
|
||||||
user_ids=user_ids,
|
|
||||||
subject="You were mentioned in a comment",
|
|
||||||
template="notifications/mention.html",
|
|
||||||
context={
|
|
||||||
'comment': comment,
|
|
||||||
'url': self._get_comment_url(comment)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Queue mobile push notifications
|
|
||||||
self.send_push_notification.delay(
|
|
||||||
user_ids=user_ids,
|
|
||||||
title="New Mention",
|
|
||||||
message=f"{comment.author} mentioned you: {comment.content[:100]}..."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Schedule SMS escalation if needed
|
|
||||||
if self.sms_enabled:
|
|
||||||
self.schedule_sms_escalation.apply_async(
|
|
||||||
args=[comment.id, user_ids],
|
|
||||||
countdown=12 * 3600 # 12 hours
|
|
||||||
)
|
|
||||||
|
|
||||||
def notify_resolution(self, thread, resolver):
|
|
||||||
"""Handle notification for thread resolution"""
|
|
||||||
self.send_in_app_notification.delay(
|
|
||||||
user_ids=self._get_thread_participants(thread),
|
|
||||||
title="Thread Resolved",
|
|
||||||
message=f"Thread resolved by {resolver}",
|
|
||||||
link=self._get_thread_url(thread)
|
|
||||||
)
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def send_in_app_notification(user_ids, title, message, link):
|
|
||||||
"""Send in-app notification to users"""
|
|
||||||
from .models import InAppNotification
|
|
||||||
for user_id in user_ids:
|
|
||||||
InAppNotification.objects.create(
|
|
||||||
user_id=user_id,
|
|
||||||
title=title,
|
|
||||||
message=message,
|
|
||||||
link=link
|
|
||||||
)
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def send_email_notification(user_ids, subject, template, context):
|
|
||||||
"""Send email notification to users"""
|
|
||||||
if not settings.EMAIL_HOST:
|
|
||||||
return
|
|
||||||
|
|
||||||
users = User.objects.filter(id__in=user_ids)
|
|
||||||
for user in users:
|
|
||||||
if not user.email:
|
|
||||||
continue
|
|
||||||
|
|
||||||
html_content = render_to_string(template, {
|
|
||||||
'user': user,
|
|
||||||
**context
|
|
||||||
})
|
|
||||||
|
|
||||||
send_mail(
|
|
||||||
subject=subject,
|
|
||||||
message='',
|
|
||||||
html_message=html_content,
|
|
||||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
|
||||||
recipient_list=[user.email]
|
|
||||||
)
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def send_push_notification(user_ids, title, message):
|
|
||||||
"""Send mobile push notification"""
|
|
||||||
from .models import PushToken
|
|
||||||
|
|
||||||
tokens = PushToken.objects.filter(
|
|
||||||
user_id__in=user_ids,
|
|
||||||
active=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if not tokens:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Implementation depends on push notification service
|
|
||||||
# Example using Firebase:
|
|
||||||
try:
|
|
||||||
requests.post(
|
|
||||||
settings.FIREBASE_FCM_URL,
|
|
||||||
headers={
|
|
||||||
'Authorization': f'key={settings.FIREBASE_SERVER_KEY}',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
json={
|
|
||||||
'registration_ids': [t.token for t in tokens],
|
|
||||||
'notification': {
|
|
||||||
'title': title,
|
|
||||||
'body': message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Push notification failed: {e}")
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def schedule_slack_escalation(comment_id):
|
|
||||||
"""Send Slack DM escalation for unread comments"""
|
|
||||||
from .models import Comment
|
|
||||||
|
|
||||||
try:
|
|
||||||
comment = Comment.objects.get(id=comment_id)
|
|
||||||
if not comment.read_by.exists():
|
|
||||||
# Send Slack message
|
|
||||||
requests.post(
|
|
||||||
settings.SLACK_WEBHOOK_URL,
|
|
||||||
json={
|
|
||||||
'text': (
|
|
||||||
f"Unread comment needs attention:\n"
|
|
||||||
f"{comment.content}\n"
|
|
||||||
f"View: {self._get_comment_url(comment)}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Slack escalation failed: {e}")
|
|
||||||
|
|
||||||
@shared_task
|
|
||||||
def schedule_sms_escalation(comment_id, user_ids):
|
|
||||||
"""Send SMS escalation for unread mentions"""
|
|
||||||
from .models import Comment
|
|
||||||
|
|
||||||
try:
|
|
||||||
comment = Comment.objects.get(id=comment_id)
|
|
||||||
users = User.objects.filter(id__in=user_ids)
|
|
||||||
|
|
||||||
for user in users:
|
|
||||||
if not user.phone_number:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not comment.read_by.filter(id=user.id).exists():
|
|
||||||
# Send SMS using Twilio or similar service
|
|
||||||
requests.post(
|
|
||||||
settings.SMS_API_URL,
|
|
||||||
headers={'Authorization': f'Bearer {settings.SMS_API_KEY}'},
|
|
||||||
json={
|
|
||||||
'to': user.phone_number,
|
|
||||||
'message': (
|
|
||||||
f"You were mentioned in a comment that needs attention. "
|
|
||||||
f"View: {self._get_comment_url(comment)}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"SMS escalation failed: {e}")
|
|
||||||
|
|
||||||
def _get_thread_participants(self, thread):
|
|
||||||
"""Get IDs of all participants in a thread"""
|
|
||||||
return list(set(
|
|
||||||
[thread.created_by_id] +
|
|
||||||
list(thread.comments.values_list('author_id', flat=True))
|
|
||||||
))
|
|
||||||
|
|
||||||
def _get_thread_url(self, thread):
|
|
||||||
"""Generate URL for thread"""
|
|
||||||
return f"/version-control/comments/thread/{thread.id}/"
|
|
||||||
|
|
||||||
def _get_comment_url(self, comment):
|
|
||||||
"""Generate URL for specific comment"""
|
|
||||||
return f"{self._get_thread_url(comment.thread)}#comment-{comment.id}"
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
from django.db.models.signals import post_save
|
|
||||||
from django.dispatch import receiver
|
|
||||||
from simple_history.signals import post_create_historical_record
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.db import transaction
|
|
||||||
from .models import VersionBranch, ChangeSet, HistoricalModel
|
|
||||||
from .managers import ChangeTracker
|
|
||||||
import threading
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
# Thread-local storage for tracking active changesets
|
|
||||||
_changeset_context = threading.local()
|
|
||||||
|
|
||||||
def get_current_branch():
|
|
||||||
"""Get the currently active branch for the thread"""
|
|
||||||
return getattr(_changeset_context, 'current_branch', None)
|
|
||||||
|
|
||||||
def set_current_branch(branch):
|
|
||||||
"""Set the active branch for the current thread"""
|
|
||||||
_changeset_context.current_branch = branch
|
|
||||||
|
|
||||||
def clear_current_branch():
|
|
||||||
"""Clear the active branch for the current thread"""
|
|
||||||
if hasattr(_changeset_context, 'current_branch'):
|
|
||||||
del _changeset_context.current_branch
|
|
||||||
|
|
||||||
class ChangesetContextManager:
|
|
||||||
"""Context manager for tracking changes in a specific branch"""
|
|
||||||
|
|
||||||
def __init__(self, branch, user=None):
|
|
||||||
self.branch = branch
|
|
||||||
self.user = user
|
|
||||||
self.previous_branch = None
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
self.previous_branch = get_current_branch()
|
|
||||||
set_current_branch(self.branch)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
set_current_branch(self.previous_branch)
|
|
||||||
|
|
||||||
@receiver(post_create_historical_record)
|
|
||||||
def handle_history_record(sender, instance, history_instance, **kwargs):
|
|
||||||
"""Handle creation of historical records by adding them to changesets"""
|
|
||||||
# Only handle records from HistoricalModel subclasses
|
|
||||||
if not isinstance(instance, HistoricalModel):
|
|
||||||
return
|
|
||||||
|
|
||||||
branch = get_current_branch()
|
|
||||||
if not branch:
|
|
||||||
# If no branch is set, use the default branch
|
|
||||||
branch, _ = VersionBranch.objects.get_or_create(
|
|
||||||
name='main',
|
|
||||||
defaults={
|
|
||||||
'metadata': {
|
|
||||||
'type': 'default_branch',
|
|
||||||
'created_automatically': True
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create or get active changeset for the current branch
|
|
||||||
changeset = getattr(_changeset_context, 'active_changeset', None)
|
|
||||||
if not changeset:
|
|
||||||
changeset = ChangeSet.objects.create(
|
|
||||||
branch=branch,
|
|
||||||
created_by=history_instance.history_user,
|
|
||||||
description=f"Automatic change tracking: {history_instance.history_type}",
|
|
||||||
metadata={
|
|
||||||
'auto_tracked': True,
|
|
||||||
'model': instance._meta.model_name,
|
|
||||||
'history_type': history_instance.history_type
|
|
||||||
},
|
|
||||||
status='applied'
|
|
||||||
)
|
|
||||||
_changeset_context.active_changeset = changeset
|
|
||||||
|
|
||||||
# Add the historical record to the changeset
|
|
||||||
changeset.historical_records.add(history_instance)
|
|
||||||
|
|
||||||
@receiver(post_save, sender=ChangeSet)
|
|
||||||
def handle_changeset_save(sender, instance, created, **kwargs):
|
|
||||||
"""Handle changeset creation by updating related objects"""
|
|
||||||
if created and instance.status == 'applied':
|
|
||||||
# Clear the active changeset if this is the one we were using
|
|
||||||
active_changeset = getattr(_changeset_context, 'active_changeset', None)
|
|
||||||
if active_changeset and active_changeset.id == instance.id:
|
|
||||||
delattr(_changeset_context, 'active_changeset')
|
|
||||||
|
|
||||||
# Update branch metadata
|
|
||||||
branch = instance.branch
|
|
||||||
if not branch.metadata.get('first_change'):
|
|
||||||
branch.metadata['first_change'] = instance.created_at.isoformat()
|
|
||||||
branch.metadata['last_change'] = instance.created_at.isoformat()
|
|
||||||
branch.metadata['change_count'] = branch.changesets.count()
|
|
||||||
branch.save()
|
|
||||||
|
|
||||||
def start_changeset(branch, user=None, description=None):
|
|
||||||
"""Start a new changeset in the given branch"""
|
|
||||||
changeset = ChangeSet.objects.create(
|
|
||||||
branch=branch,
|
|
||||||
created_by=user,
|
|
||||||
description=description or "Manual changeset",
|
|
||||||
status='pending'
|
|
||||||
)
|
|
||||||
_changeset_context.active_changeset = changeset
|
|
||||||
return changeset
|
|
||||||
|
|
||||||
def commit_changeset(success=True):
|
|
||||||
"""Commit the current changeset"""
|
|
||||||
changeset = getattr(_changeset_context, 'active_changeset', None)
|
|
||||||
if changeset:
|
|
||||||
changeset.status = 'applied' if success else 'failed'
|
|
||||||
changeset.save()
|
|
||||||
delattr(_changeset_context, 'active_changeset')
|
|
||||||
return changeset
|
|
||||||
|
|
||||||
class ChangesetManager:
|
|
||||||
"""Context manager for handling changesets"""
|
|
||||||
|
|
||||||
def __init__(self, branch, user=None, description=None):
|
|
||||||
self.branch = branch
|
|
||||||
self.user = user
|
|
||||||
self.description = description
|
|
||||||
self.changeset = None
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
self.changeset = start_changeset(
|
|
||||||
self.branch,
|
|
||||||
self.user,
|
|
||||||
self.description
|
|
||||||
)
|
|
||||||
return self.changeset
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
commit_changeset(success=exc_type is None)
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
from typing import List, Dict, Any, Optional
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.utils import timezone
|
|
||||||
from .models import ChangeSet
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
class ApprovalStage:
|
|
||||||
def __init__(self, stage_id: int, name: str, required_roles: List[str]):
|
|
||||||
self.id = stage_id
|
|
||||||
self.name = name
|
|
||||||
self.required_roles = required_roles
|
|
||||||
self.approvers: List[Dict[str, Any]] = []
|
|
||||||
self.status = 'pending' # pending, approved, rejected
|
|
||||||
self.completed_at = None
|
|
||||||
|
|
||||||
def add_approver(self, user: User, decision: str, comment: str = "") -> None:
|
|
||||||
"""Add an approver's decision to this stage"""
|
|
||||||
self.approvers.append({
|
|
||||||
'user_id': user.id,
|
|
||||||
'username': user.username,
|
|
||||||
'decision': decision,
|
|
||||||
'comment': comment,
|
|
||||||
'timestamp': timezone.now().isoformat()
|
|
||||||
})
|
|
||||||
|
|
||||||
def is_approved(self, policy: str = 'unanimous') -> bool:
|
|
||||||
"""Check if stage is approved based on policy"""
|
|
||||||
if not self.approvers:
|
|
||||||
return False
|
|
||||||
|
|
||||||
approve_count = sum(1 for a in self.approvers if a['decision'] == 'approve')
|
|
||||||
if policy == 'unanimous':
|
|
||||||
return approve_count == len(self.required_roles)
|
|
||||||
else: # majority
|
|
||||||
return approve_count > len(self.required_roles) / 2
|
|
||||||
|
|
||||||
class ApprovalStateMachine:
|
|
||||||
"""Manages the state transitions for change approval workflow"""
|
|
||||||
|
|
||||||
def __init__(self, changeset: ChangeSet):
|
|
||||||
self.changeset = changeset
|
|
||||||
self.stages: List[ApprovalStage] = []
|
|
||||||
self.current_stage_index = 0
|
|
||||||
self.policy = changeset.approval_policy
|
|
||||||
self._load_state()
|
|
||||||
|
|
||||||
def _load_state(self) -> None:
|
|
||||||
"""Load the current state from changeset approval_state"""
|
|
||||||
if not self.changeset.approval_state:
|
|
||||||
return
|
|
||||||
|
|
||||||
for stage_data in self.changeset.approval_state:
|
|
||||||
stage = ApprovalStage(
|
|
||||||
stage_data['id'],
|
|
||||||
stage_data['name'],
|
|
||||||
stage_data['required_roles']
|
|
||||||
)
|
|
||||||
stage.approvers = stage_data.get('approvers', [])
|
|
||||||
stage.status = stage_data.get('status', 'pending')
|
|
||||||
stage.completed_at = stage_data.get('completed_at')
|
|
||||||
self.stages.append(stage)
|
|
||||||
|
|
||||||
# Find current stage
|
|
||||||
self.current_stage_index = next(
|
|
||||||
(i for i, s in enumerate(self.stages) if s.status == 'pending'),
|
|
||||||
len(self.stages) - 1
|
|
||||||
)
|
|
||||||
|
|
||||||
def initialize_workflow(self, stages_config: List[Dict[str, Any]]) -> None:
|
|
||||||
"""Set up initial approval workflow stages"""
|
|
||||||
self.stages = [
|
|
||||||
ApprovalStage(
|
|
||||||
i,
|
|
||||||
stage['name'],
|
|
||||||
stage['required_roles']
|
|
||||||
) for i, stage in enumerate(stages_config)
|
|
||||||
]
|
|
||||||
self._save_state()
|
|
||||||
|
|
||||||
def submit_approval(
|
|
||||||
self,
|
|
||||||
user: User,
|
|
||||||
decision: str,
|
|
||||||
comment: str = "",
|
|
||||||
stage_id: Optional[int] = None
|
|
||||||
) -> bool:
|
|
||||||
"""
|
|
||||||
Submit an approval decision
|
|
||||||
Args:
|
|
||||||
user: The user submitting approval
|
|
||||||
decision: 'approve' or 'reject'
|
|
||||||
comment: Optional comment
|
|
||||||
stage_id: Optional specific stage ID (for parallel approval)
|
|
||||||
Returns:
|
|
||||||
bool: True if submission was accepted
|
|
||||||
"""
|
|
||||||
if self.changeset.status != 'pending_approval':
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.policy == 'sequential':
|
|
||||||
stage = self.stages[self.current_stage_index]
|
|
||||||
else: # parallel
|
|
||||||
if stage_id is None:
|
|
||||||
return False
|
|
||||||
stage = next((s for s in self.stages if s.id == stage_id), None)
|
|
||||||
if not stage:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if user has required role
|
|
||||||
user_roles = set(user.groups.values_list('name', flat=True))
|
|
||||||
if not any(role in user_roles for role in stage.required_roles):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Add decision
|
|
||||||
stage.add_approver(user, decision, comment)
|
|
||||||
|
|
||||||
# Update stage status
|
|
||||||
if stage.is_approved(self.policy):
|
|
||||||
stage.status = 'approved'
|
|
||||||
stage.completed_at = timezone.now().isoformat()
|
|
||||||
|
|
||||||
if self.policy == 'sequential':
|
|
||||||
self._advance_stage()
|
|
||||||
elif decision == 'reject':
|
|
||||||
stage.status = 'rejected'
|
|
||||||
stage.completed_at = timezone.now().isoformat()
|
|
||||||
self.changeset.status = 'rejected'
|
|
||||||
self.changeset.save()
|
|
||||||
|
|
||||||
self._save_state()
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _advance_stage(self) -> None:
|
|
||||||
"""Move to next stage if available"""
|
|
||||||
if self.current_stage_index < len(self.stages) - 1:
|
|
||||||
self.current_stage_index += 1
|
|
||||||
else:
|
|
||||||
# All stages approved
|
|
||||||
self.changeset.status = 'approved'
|
|
||||||
self.changeset.save()
|
|
||||||
|
|
||||||
def _save_state(self) -> None:
|
|
||||||
"""Save current state to changeset"""
|
|
||||||
self.changeset.approval_state = [
|
|
||||||
{
|
|
||||||
'id': stage.id,
|
|
||||||
'name': stage.name,
|
|
||||||
'required_roles': stage.required_roles,
|
|
||||||
'approvers': stage.approvers,
|
|
||||||
'status': stage.status,
|
|
||||||
'completed_at': stage.completed_at
|
|
||||||
} for stage in self.stages
|
|
||||||
]
|
|
||||||
self.changeset.save()
|
|
||||||
|
|
||||||
def get_current_stage(self) -> Optional[ApprovalStage]:
|
|
||||||
"""Get the current active stage"""
|
|
||||||
if not self.stages:
|
|
||||||
return None
|
|
||||||
return self.stages[self.current_stage_index]
|
|
||||||
|
|
||||||
def get_stage_by_id(self, stage_id: int) -> Optional[ApprovalStage]:
|
|
||||||
"""Get a specific stage by ID"""
|
|
||||||
return next((s for s in self.stages if s.id == stage_id), None)
|
|
||||||
|
|
||||||
def get_pending_approvers(self) -> List[str]:
|
|
||||||
"""Get list of roles that still need to approve the current stage"""
|
|
||||||
current_stage = self.get_current_stage()
|
|
||||||
if not current_stage:
|
|
||||||
return []
|
|
||||||
|
|
||||||
approved_by = {a['user_id'] for a in current_stage.approvers}
|
|
||||||
return [
|
|
||||||
role for role in current_stage.required_roles
|
|
||||||
if not any(
|
|
||||||
user.id in approved_by
|
|
||||||
for user in User.objects.filter(groups__name=role)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
def can_user_approve(self, user: User) -> bool:
|
|
||||||
"""Check if user can approve the current stage"""
|
|
||||||
current_stage = self.get_current_stage()
|
|
||||||
if not current_stage:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if user already approved
|
|
||||||
if any(a['user_id'] == user.id for a in current_stage.approvers):
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Check if user has required role
|
|
||||||
user_roles = set(user.groups.values_list('name', flat=True))
|
|
||||||
return any(role in user_roles for role in current_stage.required_roles)
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="approval-status">
|
|
||||||
<div class="status-header">
|
|
||||||
<h2>Approval Status</h2>
|
|
||||||
<div class="changeset-info">
|
|
||||||
<span class="changeset-id">Changeset #{{ changeset.pk }}</span>
|
|
||||||
<span class="status-badge {{ changeset.status }}">{{ changeset.status|title }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if changeset.description %}
|
|
||||||
<div class="changeset-description">
|
|
||||||
{{ changeset.description }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if current_stage %}
|
|
||||||
<div class="current-stage">
|
|
||||||
<h3>Current Stage: {{ current_stage.name }}</h3>
|
|
||||||
|
|
||||||
<div class="required-roles">
|
|
||||||
<h4>Required Approvers:</h4>
|
|
||||||
<ul class="role-list">
|
|
||||||
{% for role in current_stage.required_roles %}
|
|
||||||
<li class="role-item">{{ role }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="approvers">
|
|
||||||
<h4>Current Approvers:</h4>
|
|
||||||
{% if current_stage.approvers %}
|
|
||||||
<ul class="approvers-list">
|
|
||||||
{% for approver in current_stage.approvers %}
|
|
||||||
<li class="approver-item">
|
|
||||||
<div class="approver-info">
|
|
||||||
<span class="approver-name">{{ approver.user }}</span>
|
|
||||||
<span class="approval-date">{{ approver.timestamp|date:"Y-m-d H:i" }}</span>
|
|
||||||
</div>
|
|
||||||
{% if approver.comment %}
|
|
||||||
<div class="approval-comment">
|
|
||||||
{{ approver.comment }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="no-approvers">No approvals yet</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if can_approve %}
|
|
||||||
<div class="approval-actions">
|
|
||||||
<form method="post" action="{% url 'history_tracking:approve_changes' changeset.pk %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="comment">Comment (optional):</label>
|
|
||||||
<textarea name="comment" id="comment" rows="3" class="form-control"></textarea>
|
|
||||||
</div>
|
|
||||||
{% if current_stage.id %}
|
|
||||||
<input type="hidden" name="stage_id" value="{{ current_stage.id }}">
|
|
||||||
{% endif %}
|
|
||||||
<div class="approval-buttons">
|
|
||||||
<button type="submit" name="decision" value="approve" class="btn btn-success">
|
|
||||||
Approve Changes
|
|
||||||
</button>
|
|
||||||
<button type="submit" name="decision" value="reject" class="btn btn-danger">
|
|
||||||
Reject Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="approval-history">
|
|
||||||
<h3>Approval History</h3>
|
|
||||||
{% if changeset.approval_history %}
|
|
||||||
<div class="history-timeline">
|
|
||||||
{% for entry in changeset.approval_history %}
|
|
||||||
<div class="history-entry">
|
|
||||||
<div class="entry-header">
|
|
||||||
<span class="entry-stage">{{ entry.stage_name }}</span>
|
|
||||||
<span class="entry-date">{{ entry.timestamp|date:"Y-m-d H:i" }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="entry-content">
|
|
||||||
<span class="entry-user">{{ entry.user }}</span>
|
|
||||||
<span class="entry-action">{{ entry.action|title }}</span>
|
|
||||||
{% if entry.comment %}
|
|
||||||
<div class="entry-comment">
|
|
||||||
{{ entry.comment }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="no-history">No approval history yet</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if pending_approvers %}
|
|
||||||
<div class="pending-approvers">
|
|
||||||
<h3>Waiting for Approval From:</h3>
|
|
||||||
<ul class="pending-list">
|
|
||||||
{% for role in pending_approvers %}
|
|
||||||
<li class="pending-role">{{ role }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if messages %}
|
|
||||||
<div class="messages">
|
|
||||||
{% for message in messages %}
|
|
||||||
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}">
|
|
||||||
{{ message }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<style>
|
|
||||||
.status-badge {
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.pending_approval { background-color: #fef3c7; color: #92400e; }
|
|
||||||
.status-badge.approved { background-color: #dcfce7; color: #166534; }
|
|
||||||
.status-badge.rejected { background-color: #fee2e2; color: #991b1b; }
|
|
||||||
|
|
||||||
.history-timeline {
|
|
||||||
border-left: 2px solid #e5e7eb;
|
|
||||||
margin-left: 1rem;
|
|
||||||
padding-left: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry {
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-entry::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
left: -1.5rem;
|
|
||||||
top: 0.5rem;
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
background: #fff;
|
|
||||||
border: 2px solid #3b82f6;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.approval-actions {
|
|
||||||
margin-top: 2rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: #f9fafb;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
<div class="bg-white rounded-lg shadow p-4 mb-4">
|
|
||||||
<h3 class="text-lg font-semibold mb-4">Create New Branch</h3>
|
|
||||||
|
|
||||||
<form hx-post="{% url 'history:branch-create' %}"
|
|
||||||
hx-target="#branch-list"
|
|
||||||
hx-swap="afterbegin"
|
|
||||||
class="space-y-4">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Branch Name
|
|
||||||
</label>
|
|
||||||
<input type="text" name="name" required
|
|
||||||
placeholder="feature/my-new-branch"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md"
|
|
||||||
pattern="[a-zA-Z0-9/_-]+"
|
|
||||||
title="Only letters, numbers, underscores, forward slashes, and hyphens allowed">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Parent Branch
|
|
||||||
</label>
|
|
||||||
<select name="parent"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
|
||||||
<option value="">-- Create from root --</option>
|
|
||||||
{% for branch in branches %}
|
|
||||||
<option value="{{ branch.name }}">{{ branch.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<p class="text-sm text-gray-500 mt-1">
|
|
||||||
Leave empty to create from root
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3">
|
|
||||||
<button type="button"
|
|
||||||
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
|
||||||
onclick="document.getElementById('branch-form-container').innerHTML = ''">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600">
|
|
||||||
Create Branch
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
|
||||||
if (evt.detail.successful && evt.detail.target.id === 'branch-list') {
|
|
||||||
document.getElementById('branch-form-container').innerHTML = '';
|
|
||||||
document.body.dispatchEvent(new CustomEvent('branch-updated'));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<div class="branch-list space-y-2">
|
|
||||||
{% for branch in branches %}
|
|
||||||
<div class="branch-item p-2 bg-gray-50 rounded hover:bg-gray-100 cursor-pointer {% if branch.name == current_branch %}bg-blue-50 border-l-4 border-blue-500{% endif %}"
|
|
||||||
hx-get="{% url 'history:history-view' %}?branch={{ branch.name }}"
|
|
||||||
hx-target="#history-view"
|
|
||||||
hx-trigger="click"
|
|
||||||
onclick="selectBranch('{{ branch.name }}')">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<span class="font-medium text-gray-700">{{ branch.name }}</span>
|
|
||||||
{% if branch.is_active %}
|
|
||||||
<span class="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">Active</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if branch.parent %}
|
|
||||||
<div class="text-xs text-gray-500 mt-1">
|
|
||||||
from: {{ branch.parent.name }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div class="flex space-x-2 mt-2">
|
|
||||||
<button class="text-xs bg-blue-500 text-white px-2 py-1 rounded hover:bg-blue-600"
|
|
||||||
hx-get="{% url 'history:merge-view' %}?source={{ branch.name }}"
|
|
||||||
hx-target="#merge-panel"
|
|
||||||
hx-trigger="click">
|
|
||||||
Merge
|
|
||||||
</button>
|
|
||||||
<button class="text-xs bg-purple-500 text-white px-2 py-1 rounded hover:bg-purple-600"
|
|
||||||
hx-get="{% url 'history:tag-create' %}?branch={{ branch.name }}"
|
|
||||||
hx-target="#tag-form-container"
|
|
||||||
hx-trigger="click">
|
|
||||||
Tag
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function selectBranch(name) {
|
|
||||||
document.body.dispatchEvent(new CustomEvent('branch-selected', {
|
|
||||||
detail: { branch: name }
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
<div class="history-view">
|
|
||||||
<h3 class="text-xl font-semibold mb-4">Change History</h3>
|
|
||||||
|
|
||||||
{% if changes %}
|
|
||||||
<div class="space-y-4">
|
|
||||||
{% for change in changes %}
|
|
||||||
<div class="change-item bg-gray-50 p-4 rounded-lg">
|
|
||||||
<div class="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h4 class="font-medium text-gray-900">{{ change.description }}</h4>
|
|
||||||
<div class="text-sm text-gray-500 mt-1">
|
|
||||||
{{ change.created_at|date:"M d, Y H:i" }}
|
|
||||||
{% if change.created_by %}
|
|
||||||
by {{ change.created_by.username }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="px-2 py-1 text-sm rounded
|
|
||||||
{% if change.status == 'applied' %}
|
|
||||||
bg-green-100 text-green-800
|
|
||||||
{% elif change.status == 'pending' %}
|
|
||||||
bg-yellow-100 text-yellow-800
|
|
||||||
{% elif change.status == 'failed' %}
|
|
||||||
bg-red-100 text-red-800
|
|
||||||
{% elif change.status == 'reverted' %}
|
|
||||||
bg-gray-100 text-gray-800
|
|
||||||
{% endif %}">
|
|
||||||
{{ change.status|title }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if change.historical_records.exists %}
|
|
||||||
<div class="mt-3 space-y-2">
|
|
||||||
{% for record in change.historical_records.all %}
|
|
||||||
<div class="text-sm bg-white p-2 rounded border border-gray-200">
|
|
||||||
<div class="font-medium">
|
|
||||||
{{ record.instance_type|title }}
|
|
||||||
{% if record.history_type == '+' %}
|
|
||||||
created
|
|
||||||
{% elif record.history_type == '-' %}
|
|
||||||
deleted
|
|
||||||
{% else %}
|
|
||||||
modified
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if record.history_type == '~' and record.diff_to_prev %}
|
|
||||||
<div class="mt-1 text-gray-600">
|
|
||||||
Changes:
|
|
||||||
<ul class="list-disc list-inside">
|
|
||||||
{% for field, values in record.diff_to_prev.items %}
|
|
||||||
<li>{{ field }}: {{ values.old }} → {{ values.new }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if change.metadata %}
|
|
||||||
<div class="mt-3 text-sm text-gray-600">
|
|
||||||
<details>
|
|
||||||
<summary class="cursor-pointer">Additional Details</summary>
|
|
||||||
<pre class="mt-2 bg-gray-100 p-2 rounded text-xs overflow-x-auto">{{ change.metadata|pprint }}</pre>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if change.status == 'applied' %}
|
|
||||||
<div class="mt-3">
|
|
||||||
<button class="text-sm bg-red-500 text-white px-3 py-1 rounded hover:bg-red-600"
|
|
||||||
hx-post="{% url 'history:revert-change' change.id %}"
|
|
||||||
hx-confirm="Are you sure you want to revert this change?"
|
|
||||||
hx-target="#history-view">
|
|
||||||
Revert Change
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-gray-500 text-center py-8">
|
|
||||||
No changes recorded for this branch yet.
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
<div class="merge-conflicts bg-white rounded-lg shadow p-4">
|
|
||||||
<div class="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-4">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-lg font-medium text-yellow-800">
|
|
||||||
Merge Conflicts Detected
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-yellow-700">
|
|
||||||
<p>Conflicts were found while merging '{{ source.name }}' into '{{ target.name }}'.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h4 class="font-medium text-gray-900">Conflicts to Resolve:</h4>
|
|
||||||
|
|
||||||
<form hx-post="{% url 'history:resolve-conflicts' %}"
|
|
||||||
hx-target="#merge-panel">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="source" value="{{ source.name }}">
|
|
||||||
<input type="hidden" name="target" value="{{ target.name }}">
|
|
||||||
|
|
||||||
{% for conflict in conflicts %}
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg mb-4">
|
|
||||||
<div class="flex justify-between items-start mb-2">
|
|
||||||
<h5 class="font-medium text-gray-900">
|
|
||||||
Conflict #{{ forloop.counter }}
|
|
||||||
</h5>
|
|
||||||
<span class="text-sm text-gray-500">
|
|
||||||
Type: {{ conflict.type }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 mb-3">
|
|
||||||
{{ conflict.description }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if conflict.type == 'content_conflict' %}
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Source Version ({{ source.name }})
|
|
||||||
</label>
|
|
||||||
<div class="bg-white p-2 rounded border border-gray-200">
|
|
||||||
<pre class="text-sm overflow-x-auto">{{ conflict.source_content }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Target Version ({{ target.name }})
|
|
||||||
</label>
|
|
||||||
<div class="bg-white p-2 rounded border border-gray-200">
|
|
||||||
<pre class="text-sm overflow-x-auto">{{ conflict.target_content }}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Resolution
|
|
||||||
</label>
|
|
||||||
<select name="resolution_{{ conflict.source_change }}_{{ conflict.target_change }}"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
|
||||||
<option value="source">Use source version</option>
|
|
||||||
<option value="target">Use target version</option>
|
|
||||||
<option value="manual">Resolve manually</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="manual-resolution hidden">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Manual Resolution
|
|
||||||
</label>
|
|
||||||
<textarea name="manual_{{ conflict.source_change }}_{{ conflict.target_change }}"
|
|
||||||
rows="4"
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md"
|
|
||||||
placeholder="Enter manual resolution here..."></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 mt-4">
|
|
||||||
<button type="button"
|
|
||||||
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
|
||||||
onclick="document.getElementById('merge-panel').innerHTML = ''">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600">
|
|
||||||
Apply Resolutions
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('change', function(e) {
|
|
||||||
if (e.target.tagName === 'SELECT' && e.target.name.startsWith('resolution_')) {
|
|
||||||
const manualDiv = e.target.parentElement.nextElementSibling;
|
|
||||||
if (e.target.value === 'manual') {
|
|
||||||
manualDiv.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
manualDiv.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<div class="merge-panel">
|
|
||||||
<h3 class="text-xl font-semibold mb-4">Merge Branches</h3>
|
|
||||||
|
|
||||||
<form hx-post="{% url 'history:merge-view' %}"
|
|
||||||
hx-target="#merge-panel"
|
|
||||||
class="space-y-4">
|
|
||||||
{% csrf_token %}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Source Branch
|
|
||||||
</label>
|
|
||||||
<input type="text" name="source" value="{{ source }}" readonly
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Target Branch
|
|
||||||
</label>
|
|
||||||
<select name="target" required
|
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md">
|
|
||||||
<option value="">Select target branch...</option>
|
|
||||||
{% for branch in branches %}
|
|
||||||
{% if branch.name != source %}
|
|
||||||
<option value="{{ branch.name }}"
|
|
||||||
{% if branch.name == target %}selected{% endif %}>
|
|
||||||
{{ branch.name }}
|
|
||||||
</option>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3">
|
|
||||||
<button type="button"
|
|
||||||
class="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
|
||||||
onclick="document.getElementById('merge-panel').innerHTML = ''">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600">
|
|
||||||
Start Merge
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<div class="merge-result bg-white rounded-lg shadow p-4">
|
|
||||||
<div class="bg-green-50 border-l-4 border-green-400 p-4">
|
|
||||||
<div class="flex">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<svg class="h-5 w-5 text-green-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<h3 class="text-lg font-medium text-green-800">
|
|
||||||
Merge Successful
|
|
||||||
</h3>
|
|
||||||
<div class="mt-2 text-sm text-green-700">
|
|
||||||
<p>Successfully merged branch '{{ source.name }}' into '{{ target.name }}'.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<button class="text-sm bg-gray-100 text-gray-700 px-3 py-1 rounded hover:bg-gray-200"
|
|
||||||
onclick="document.getElementById('merge-panel').innerHTML = ''">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.body.dispatchEvent(new CustomEvent('branch-updated'));
|
|
||||||
</script>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
{% if version_control.vcs_enabled and version_control.page_is_versioned %}
|
|
||||||
<div class="version-control-ui bg-white shadow-sm rounded-lg p-4 mb-4">
|
|
||||||
<!-- Branch Information -->
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold">Version Control</h3>
|
|
||||||
{% if version_control.current_branch %}
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
Current Branch:
|
|
||||||
<span class="font-medium">{{ version_control.branch_name }}</span>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Branch Selection -->
|
|
||||||
<div class="relative" x-data="{ open: false }">
|
|
||||||
<button @click="open = !open"
|
|
||||||
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
|
|
||||||
Branch Actions
|
|
||||||
</button>
|
|
||||||
<div x-show="open"
|
|
||||||
@click.away="open = false"
|
|
||||||
class="absolute right-0 mt-2 py-2 w-48 bg-white rounded-lg shadow-xl z-50">
|
|
||||||
<!-- Create Branch -->
|
|
||||||
<button class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left"
|
|
||||||
hx-get="{% url 'history:branch-create' %}"
|
|
||||||
hx-target="#branch-form-container">
|
|
||||||
Create Branch
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Switch Branch -->
|
|
||||||
{% if version_control.available_branches %}
|
|
||||||
<div class="border-t border-gray-100 my-2"></div>
|
|
||||||
{% for branch in version_control.available_branches %}
|
|
||||||
<button class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 w-full text-left"
|
|
||||||
hx-post="{% url 'history:switch-branch' %}"
|
|
||||||
hx-vals='{"branch": "{{ branch.name }}"}'
|
|
||||||
hx-target="body">
|
|
||||||
Switch to {{ branch.name }}
|
|
||||||
</button>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recent Changes -->
|
|
||||||
{% if version_control.recent_changes %}
|
|
||||||
<div class="mt-4">
|
|
||||||
<h4 class="text-sm font-semibold text-gray-700 mb-2">Recent Changes</h4>
|
|
||||||
<div class="space-y-2">
|
|
||||||
{% for change in version_control.recent_changes %}
|
|
||||||
<div class="bg-gray-50 p-2 rounded text-sm">
|
|
||||||
<div class="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium">{{ change.description }}</span>
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
{{ change.created_at|date:"M d, Y H:i" }}
|
|
||||||
{% if change.created_by %}
|
|
||||||
by {{ change.created_by.username }}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="px-2 py-1 text-xs rounded
|
|
||||||
{% if change.status == 'applied' %}
|
|
||||||
bg-green-100 text-green-800
|
|
||||||
{% elif change.status == 'pending' %}
|
|
||||||
bg-yellow-100 text-yellow-800
|
|
||||||
{% elif change.status == 'failed' %}
|
|
||||||
bg-red-100 text-red-800
|
|
||||||
{% endif %}">
|
|
||||||
{{ change.status|title }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Branch Form Container -->
|
|
||||||
<div id="branch-form-container"></div>
|
|
||||||
|
|
||||||
<!-- Merge Panel -->
|
|
||||||
<div id="merge-panel"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Scripts -->
|
|
||||||
<script>
|
|
||||||
document.body.addEventListener('branch-switched', function(e) {
|
|
||||||
location.reload();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Version Control Monitoring - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_css %}
|
|
||||||
<link rel="stylesheet" href="{% static 'css/monitoring.css' %}">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-8">Version Control Monitoring</h1>
|
|
||||||
|
|
||||||
<!-- System Overview -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h3 class="text-lg font-semibold mb-2">Total Branches</h3>
|
|
||||||
<p class="text-3xl font-bold text-blue-600">{{ metrics.total_branches }}</p>
|
|
||||||
<p class="text-sm text-gray-500 mt-2">{{ metrics.active_branches }} active</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h3 class="text-lg font-semibold mb-2">Total Changes</h3>
|
|
||||||
<p class="text-3xl font-bold text-green-600">{{ metrics.total_changes }}</p>
|
|
||||||
<p class="text-sm text-gray-500 mt-2">{{ metrics.pending_changes }} pending</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h3 class="text-lg font-semibold mb-2">Merge Success Rate</h3>
|
|
||||||
<p class="text-3xl font-bold text-indigo-600">{{ metrics.merge_success_rate }}%</p>
|
|
||||||
<p class="text-sm text-gray-500 mt-2">{{ metrics.conflicted_merges }} conflicts</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h3 class="text-lg font-semibold mb-2">System Health</h3>
|
|
||||||
<p class="text-3xl font-bold {% if metrics.system_health >= 90 %}text-green-600{% elif metrics.system_health >= 70 %}text-yellow-600{% else %}text-red-600{% endif %}">
|
|
||||||
{{ metrics.system_health }}%
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-500 mt-2">Based on {{ metrics.health_checks }} checks</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Performance Metrics -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-8">
|
|
||||||
<h2 class="text-xl font-bold mb-4">Performance Metrics</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<!-- Operation Timing -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-3">Operation Timing (avg)</h3>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">Branch Creation</span>
|
|
||||||
<span class="font-medium">{{ metrics.timing.branch_creation }}ms</span>
|
|
||||||
</li>
|
|
||||||
<li class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">Branch Switch</span>
|
|
||||||
<span class="font-medium">{{ metrics.timing.branch_switch }}ms</span>
|
|
||||||
</li>
|
|
||||||
<li class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">Merge Operation</span>
|
|
||||||
<span class="font-medium">{{ metrics.timing.merge }}ms</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Database Metrics -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-3">Database Performance</h3>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">Query Count (avg)</span>
|
|
||||||
<span class="font-medium">{{ metrics.database.query_count }}</span>
|
|
||||||
</li>
|
|
||||||
<li class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">Query Time (avg)</span>
|
|
||||||
<span class="font-medium">{{ metrics.database.query_time }}ms</span>
|
|
||||||
</li>
|
|
||||||
<li class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">Connection Pool</span>
|
|
||||||
<span class="font-medium">{{ metrics.database.pool_size }}/{{ metrics.database.max_pool }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cache Metrics -->
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-3">Cache Performance</h3>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
<li class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">Hit Rate</span>
|
|
||||||
<span class="font-medium">{{ metrics.cache.hit_rate }}%</span>
|
|
||||||
</li>
|
|
||||||
<li class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">Miss Rate</span>
|
|
||||||
<span class="font-medium">{{ metrics.cache.miss_rate }}%</span>
|
|
||||||
</li>
|
|
||||||
<li class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">Memory Usage</span>
|
|
||||||
<span class="font-medium">{{ metrics.cache.memory_usage }}MB</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Error Tracking -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-8">
|
|
||||||
<h2 class="text-xl font-bold mb-4">Error Tracking</h2>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time</th>
|
|
||||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
|
||||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Operation</th>
|
|
||||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Message</th>
|
|
||||||
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
|
||||||
{% for error in metrics.errors %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ error.timestamp }}</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ error.type }}</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ error.operation }}</td>
|
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">{{ error.message }}</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {% if error.resolved %}bg-green-100 text-green-800{% else %}bg-red-100 text-red-800{% endif %}">
|
|
||||||
{{ error.resolved|yesno:"Resolved,Unresolved" }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Active Users -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h2 class="text-xl font-bold mb-4">Active Users</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-3">Current Operations</h3>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{% for operation in metrics.current_operations %}
|
|
||||||
<li class="flex justify-between items-center">
|
|
||||||
<span class="text-gray-600">{{ operation.user }}</span>
|
|
||||||
<span class="text-sm">{{ operation.action }}</span>
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold mb-3">Recent Activity</h3>
|
|
||||||
<ul class="space-y-2">
|
|
||||||
{% for activity in metrics.recent_activity %}
|
|
||||||
<li class="text-sm text-gray-600">
|
|
||||||
{{ activity.user }} {{ activity.action }} {{ activity.timestamp|timesince }} ago
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
<script src="{% static 'js/monitoring.js' %}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
{% if notifications %}
|
|
||||||
<div class="approval-notifications" id="approval-notifications">
|
|
||||||
{% for notification in notifications %}
|
|
||||||
<div class="notification-item {% if notification.is_new %}new{% endif %}"
|
|
||||||
{% if notification.is_new %}
|
|
||||||
hx-swap-oob="afterbegin:.approval-notifications"
|
|
||||||
{% endif %}>
|
|
||||||
<div class="notification-header">
|
|
||||||
<span class="notification-type">{{ notification.type }}</span>
|
|
||||||
<span class="notification-time">{{ notification.timestamp|timesince }} ago</span>
|
|
||||||
</div>
|
|
||||||
<div class="notification-content">
|
|
||||||
{% if notification.type == 'approval' %}
|
|
||||||
<span class="approver">{{ notification.user }}</span>
|
|
||||||
{{ notification.action }} the changes
|
|
||||||
{% if notification.stage %}
|
|
||||||
in stage <strong>{{ notification.stage }}</strong>
|
|
||||||
{% endif %}
|
|
||||||
{% elif notification.type == 'comment' %}
|
|
||||||
<span class="commenter">{{ notification.user }}</span>
|
|
||||||
commented on the changes
|
|
||||||
{% elif notification.type == 'stage_change' %}
|
|
||||||
Moved to stage <strong>{{ notification.stage }}</strong>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if notification.comment %}
|
|
||||||
<div class="notification-comment">
|
|
||||||
"{{ notification.comment }}"
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if notification.actions %}
|
|
||||||
<div class="notification-actions">
|
|
||||||
{% for action in notification.actions %}
|
|
||||||
<button class="btn btn-sm btn-link"
|
|
||||||
hx-post="{{ action.url }}"
|
|
||||||
hx-trigger="click"
|
|
||||||
hx-target="#approval-status-container">
|
|
||||||
{{ action.label }}
|
|
||||||
</button>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="no-notifications" id="approval-notifications">
|
|
||||||
No recent notifications
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
{% if current_stage %}
|
|
||||||
<div id="approval-status-container">
|
|
||||||
<div class="current-stage-info">
|
|
||||||
<h3>Current Stage: {{ current_stage.name }}</h3>
|
|
||||||
|
|
||||||
<div class="required-roles">
|
|
||||||
<h4>Required Approvers:</h4>
|
|
||||||
<ul class="role-list">
|
|
||||||
{% for role in current_stage.required_roles %}
|
|
||||||
<li class="role-item {% if role in pending_approvers %}pending{% endif %}">
|
|
||||||
{{ role }}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="approvers">
|
|
||||||
<h4>Current Approvers:</h4>
|
|
||||||
{% if current_stage.approvers %}
|
|
||||||
<ul class="approvers-list">
|
|
||||||
{% for approver in current_stage.approvers %}
|
|
||||||
<li class="approver-item">
|
|
||||||
<div class="approver-info">
|
|
||||||
<span class="approver-name">{{ approver.user }}</span>
|
|
||||||
<span class="approval-date">{{ approver.timestamp|date:"Y-m-d H:i" }}</span>
|
|
||||||
</div>
|
|
||||||
{% if approver.comment %}
|
|
||||||
<div class="approval-comment">
|
|
||||||
{{ approver.comment }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% else %}
|
|
||||||
<p class="no-approvers">No approvals yet</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if can_approve %}
|
|
||||||
<div class="approval-actions">
|
|
||||||
<form hx-post="{% url 'history_tracking:approve_changes' changeset.pk %}"
|
|
||||||
hx-trigger="submit"
|
|
||||||
hx-target="#approval-status-container"
|
|
||||||
hx-swap="outerHTML">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="comment">Comment (optional):</label>
|
|
||||||
<textarea name="comment"
|
|
||||||
id="comment"
|
|
||||||
rows="3"
|
|
||||||
class="form-control"
|
|
||||||
hx-post="{% url 'history_tracking:preview_comment' %}"
|
|
||||||
hx-trigger="keyup changed delay:500ms"
|
|
||||||
hx-target="#comment-preview"></textarea>
|
|
||||||
<div id="comment-preview" class="comment-preview"></div>
|
|
||||||
</div>
|
|
||||||
{% if current_stage.id %}
|
|
||||||
<input type="hidden" name="stage_id" value="{{ current_stage.id }}">
|
|
||||||
{% endif %}
|
|
||||||
<div class="approval-buttons">
|
|
||||||
<button type="submit"
|
|
||||||
name="decision"
|
|
||||||
value="approve"
|
|
||||||
class="btn btn-success"
|
|
||||||
{% if not can_approve %}disabled{% endif %}
|
|
||||||
hx-indicator="#approve-indicator">
|
|
||||||
Approve Changes
|
|
||||||
<span class="htmx-indicator" id="approve-indicator">
|
|
||||||
<span class="spinner"></span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
name="decision"
|
|
||||||
value="reject"
|
|
||||||
class="btn btn-danger"
|
|
||||||
{% if not can_approve %}disabled{% endif %}
|
|
||||||
hx-indicator="#reject-indicator">
|
|
||||||
Reject Changes
|
|
||||||
<span class="htmx-indicator" id="reject-indicator">
|
|
||||||
<span class="spinner"></span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if messages %}
|
|
||||||
<div class="approval-messages"
|
|
||||||
hx-swap-oob="true">
|
|
||||||
{% for message in messages %}
|
|
||||||
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
|
|
||||||
role="alert">
|
|
||||||
{{ message }}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{% if content %}
|
|
||||||
<div class="comment-preview-content">
|
|
||||||
<h6>Preview:</h6>
|
|
||||||
<div class="preview-text">{{ content }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{% if replies %}
|
|
||||||
<div class="replies-list">
|
|
||||||
{% for reply in replies %}
|
|
||||||
<div class="reply" id="comment-{{ reply.id }}">
|
|
||||||
<div class="reply-header">
|
|
||||||
<span class="reply-author">{{ reply.author }}</span>
|
|
||||||
<span class="reply-date">{{ reply.created_at|date:"Y-m-d H:i" }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="reply-content">
|
|
||||||
{{ reply.content }}
|
|
||||||
</div>
|
|
||||||
{% if user.has_perm 'history_tracking.add_comment' %}
|
|
||||||
<div class="reply-actions">
|
|
||||||
<button class="btn btn-sm btn-link"
|
|
||||||
hx-get="{% url 'history_tracking:reply_form' %}?parent_id={{ reply.id }}"
|
|
||||||
hx-trigger="click"
|
|
||||||
hx-target="#reply-form-{{ reply.id }}"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="reply-form-{{ reply.id }}"></div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{% if comments %}
|
|
||||||
<div class="comments-list">
|
|
||||||
{% for comment in comments %}
|
|
||||||
<div class="comment" id="comment-{{ comment.id }}"
|
|
||||||
{% if forloop.first %}hx-swap-oob="true"{% endif %}>
|
|
||||||
<div class="comment-header">
|
|
||||||
<span class="comment-author">{{ comment.author }}</span>
|
|
||||||
<span class="comment-date">{{ comment.created_at|date:"Y-m-d H:i" }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="comment-content">{{ comment.content }}</div>
|
|
||||||
{% if user.has_perm 'history_tracking.add_comment' %}
|
|
||||||
<div class="comment-actions">
|
|
||||||
<button class="btn btn-sm btn-link"
|
|
||||||
hx-get="{% url 'history_tracking:reply_form' %}"
|
|
||||||
hx-trigger="click"
|
|
||||||
hx-target="#reply-form-{{ comment.id }}"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="reply-form-{{ comment.id }}"></div>
|
|
||||||
{% endif %}
|
|
||||||
{% if comment.replies.exists %}
|
|
||||||
<div class="comment-replies"
|
|
||||||
hx-get="{% url 'history_tracking:get_replies' comment.id %}"
|
|
||||||
hx-trigger="load">
|
|
||||||
<div class="htmx-indicator">Loading replies...</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="no-comments">No comments yet</p>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<div class="reply-form-container">
|
|
||||||
<form hx-post="{% url 'history_tracking:add_comment' %}"
|
|
||||||
hx-trigger="submit"
|
|
||||||
hx-target="closest .comments-list"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
class="reply-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="parent_id" value="{{ parent_id }}">
|
|
||||||
<input type="hidden" name="anchor" value="{{ anchor }}">
|
|
||||||
<div class="form-group">
|
|
||||||
<textarea name="content"
|
|
||||||
class="form-control reply-input"
|
|
||||||
placeholder="Write a reply..."
|
|
||||||
rows="2"
|
|
||||||
hx-post="{% url 'history_tracking:preview_comment' %}"
|
|
||||||
hx-trigger="keyup changed delay:500ms"
|
|
||||||
hx-target="#reply-preview-{{ parent_id }}"></textarea>
|
|
||||||
<div id="reply-preview-{{ parent_id }}" class="reply-preview"></div>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button type="button"
|
|
||||||
class="btn btn-sm btn-light"
|
|
||||||
onclick="this.closest('.reply-form-container').remove()">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="btn btn-sm btn-primary"
|
|
||||||
hx-disable-if="querySelector('.reply-input').value === ''">
|
|
||||||
Submit Reply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="version-comparison">
|
|
||||||
<div class="comparison-header">
|
|
||||||
<h2>Version Comparison</h2>
|
|
||||||
|
|
||||||
{# Version Selection Form with HTMX #}
|
|
||||||
<div class="version-select-form">
|
|
||||||
<div class="version-selectors">
|
|
||||||
<div class="version-select">
|
|
||||||
<label for="version1">First Version</label>
|
|
||||||
<select name="version1" id="version1"
|
|
||||||
hx-get="{% url 'history_tracking:version_comparison' %}"
|
|
||||||
hx-trigger="change"
|
|
||||||
hx-target="#comparison-results"
|
|
||||||
hx-indicator=".loading-indicator">
|
|
||||||
{% for version in versions %}
|
|
||||||
<option value="{{ version.id }}" {% if version.id == selected_version1 %}selected{% endif %}>
|
|
||||||
{{ version.name }} ({{ version.created_at|date:"Y-m-d H:i" }})
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="version-select">
|
|
||||||
<label for="version2">Second Version</label>
|
|
||||||
<select name="version2" id="version2"
|
|
||||||
hx-get="{% url 'history_tracking:version_comparison' %}"
|
|
||||||
hx-trigger="change"
|
|
||||||
hx-target="#comparison-results"
|
|
||||||
hx-indicator=".loading-indicator">
|
|
||||||
{% for version in versions %}
|
|
||||||
<option value="{{ version.id }}" {% if version.id == selected_version2 %}selected{% endif %}>
|
|
||||||
{{ version.name }} ({{ version.created_at|date:"Y-m-d H:i" }})
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="loading-indicator htmx-indicator">Loading comparison...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if diff_result %}
|
|
||||||
<div class="comparison-results">
|
|
||||||
<div class="diff-summary">
|
|
||||||
<h3>Changes Summary</h3>
|
|
||||||
<div class="diff-stats">
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-label">Files Changed:</span>
|
|
||||||
<span class="stat-value">{{ diff_result.stats.total_files }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-label">Lines Changed:</span>
|
|
||||||
<span class="stat-value">{{ diff_result.stats.total_lines }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<span class="stat-label">Impact Score:</span>
|
|
||||||
<span class="stat-value">{{ diff_result.impact_score|floatformat:2 }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="changes-list">
|
|
||||||
{% for change in diff_result.changes %}
|
|
||||||
<div class="change-item">
|
|
||||||
<div class="change-header">
|
|
||||||
<h4>{{ change.field }}</h4>
|
|
||||||
<span class="change-type">{{ change.type }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="diff-view {% if change.syntax_type %}language-{{ change.syntax_type }}{% endif %}">
|
|
||||||
<div class="old-version">
|
|
||||||
<div class="version-header">Previous Version</div>
|
|
||||||
<pre><code>{{ change.old }}</code></pre>
|
|
||||||
</div>
|
|
||||||
<div class="new-version">
|
|
||||||
<div class="version-header">New Version</div>
|
|
||||||
<pre><code>{{ change.new }}</code></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if user.has_perm 'history_tracking.add_comment' %}
|
|
||||||
<div class="comment-section"
|
|
||||||
id="comments-{{ change.metadata.comment_anchor_id }}"
|
|
||||||
hx-get="{% url 'history_tracking:get_comments' %}"
|
|
||||||
hx-trigger="load, commentAdded from:body"
|
|
||||||
hx-vals='{"anchor": "{{ change.metadata.comment_anchor_id }}"}'>
|
|
||||||
<div class="htmx-indicator">Loading comments...</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="comment-form-container">
|
|
||||||
<form hx-post="{% url 'history_tracking:add_comment' %}"
|
|
||||||
hx-trigger="submit"
|
|
||||||
hx-target="#comments-{{ change.metadata.comment_anchor_id }}"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
class="comment-form">
|
|
||||||
{% csrf_token %}
|
|
||||||
<input type="hidden" name="anchor" value="{{ change.metadata.comment_anchor_id }}">
|
|
||||||
<textarea name="content"
|
|
||||||
placeholder="Add a comment..."
|
|
||||||
hx-post="{% url 'history_tracking:preview_comment' %}"
|
|
||||||
hx-trigger="keyup changed delay:500ms"
|
|
||||||
hx-target="#comment-preview-{{ change.metadata.comment_anchor_id }}"
|
|
||||||
class="comment-input"></textarea>
|
|
||||||
<div id="comment-preview-{{ change.metadata.comment_anchor_id }}"
|
|
||||||
class="comment-preview"></div>
|
|
||||||
<button type="submit"
|
|
||||||
class="btn btn-sm btn-primary"
|
|
||||||
hx-disable-if="querySelector('.comment-input').value === ''">
|
|
||||||
Comment
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if diff_result.changes.has_other_pages %}
|
|
||||||
<div class="pagination">
|
|
||||||
<span class="step-links">
|
|
||||||
{% if diff_result.changes.has_previous %}
|
|
||||||
<a href="?page=1">« first</a>
|
|
||||||
<a href="?page={{ diff_result.changes.previous_page_number }}">previous</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<span class="current">
|
|
||||||
Page {{ diff_result.changes.number }} of {{ diff_result.changes.paginator.num_pages }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{% if diff_result.changes.has_next %}
|
|
||||||
<a href="?page={{ diff_result.changes.next_page_number }}">next</a>
|
|
||||||
<a href="?page={{ diff_result.changes.paginator.num_pages }}">last »</a>
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% comment %}
|
|
||||||
Only include minimal JavaScript for necessary interactivity
|
|
||||||
{% endcomment %}
|
|
||||||
{% block extra_js %}
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Handle syntax highlighting
|
|
||||||
document.querySelectorAll('pre code').forEach((block) => {
|
|
||||||
const language = block.parentElement.parentElement.dataset.language;
|
|
||||||
if (language) {
|
|
||||||
block.classList.add(`language-${language}`);
|
|
||||||
hljs.highlightElement(block);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Simple form validation
|
|
||||||
document.querySelector('.version-select-form').addEventListener('submit', function(e) {
|
|
||||||
const v1 = document.getElementById('version1').value;
|
|
||||||
const v2 = document.getElementById('version2').value;
|
|
||||||
if (v1 === v2) {
|
|
||||||
e.preventDefault();
|
|
||||||
alert('Please select different versions to compare');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="version-control-panel p-4">
|
|
||||||
<div class="grid grid-cols-12 gap-4">
|
|
||||||
<!-- Left Sidebar -->
|
|
||||||
<div class="col-span-3 bg-white rounded-lg shadow p-4">
|
|
||||||
<div class="mb-4">
|
|
||||||
<h3 class="text-lg font-semibold mb-2">Branches</h3>
|
|
||||||
<button
|
|
||||||
class="w-full bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
|
||||||
hx-get="{% url 'history:branch-create' %}"
|
|
||||||
hx-target="#branch-form-container"
|
|
||||||
>
|
|
||||||
New Branch
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Branch List -->
|
|
||||||
<div
|
|
||||||
id="branch-list"
|
|
||||||
hx-get="{% url 'history:branch-list' %}"
|
|
||||||
hx-trigger="load, branch-updated from:body">
|
|
||||||
<!-- Branch list will be loaded here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
|
||||||
<div class="col-span-9">
|
|
||||||
<!-- Branch Form Container -->
|
|
||||||
<div id="branch-form-container"></div>
|
|
||||||
|
|
||||||
<!-- History View -->
|
|
||||||
<div
|
|
||||||
id="history-view"
|
|
||||||
class="bg-white rounded-lg shadow p-4 mb-4"
|
|
||||||
hx-get="{% url 'history:history-view' %}?branch={{ current_branch }}"
|
|
||||||
hx-trigger="load, branch-selected from:body">
|
|
||||||
<!-- History will be loaded here -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Merge Panel -->
|
|
||||||
<div
|
|
||||||
id="merge-panel"
|
|
||||||
class="bg-white rounded-lg shadow p-4"
|
|
||||||
hx-get="{% url 'history:merge-view' %}"
|
|
||||||
hx-trigger="merge-initiated from:body">
|
|
||||||
<!-- Merge interface will be loaded here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.db import transaction
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from history_tracking.models import VersionBranch, ChangeSet
|
|
||||||
from history_tracking.managers import BranchManager, MergeStrategy
|
|
||||||
from parks.models import Park
|
|
||||||
|
|
||||||
class BranchManagerTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.park = Park.objects.create(
|
|
||||||
name='Test Park',
|
|
||||||
slug='test-park',
|
|
||||||
status='OPERATING'
|
|
||||||
)
|
|
||||||
self.content_type = ContentType.objects.get_for_model(Park)
|
|
||||||
self.manager = BranchManager()
|
|
||||||
self.main_branch = VersionBranch.objects.create(
|
|
||||||
name='main',
|
|
||||||
metadata={'type': 'default_branch'}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_create_branch(self):
|
|
||||||
"""Test branch creation with metadata"""
|
|
||||||
branch = self.manager.create_branch(
|
|
||||||
name='feature/test',
|
|
||||||
metadata={'type': 'feature', 'description': 'Test branch'}
|
|
||||||
)
|
|
||||||
self.assertEqual(branch.name, 'feature/test')
|
|
||||||
self.assertEqual(branch.metadata['type'], 'feature')
|
|
||||||
self.assertTrue(branch.is_active)
|
|
||||||
|
|
||||||
def test_get_active_branches(self):
|
|
||||||
"""Test retrieving only active branches"""
|
|
||||||
# Create some branches
|
|
||||||
feature_branch = self.manager.create_branch(
|
|
||||||
name='feature/active',
|
|
||||||
metadata={'type': 'feature'}
|
|
||||||
)
|
|
||||||
inactive_branch = self.manager.create_branch(
|
|
||||||
name='feature/inactive',
|
|
||||||
metadata={'type': 'feature'}
|
|
||||||
)
|
|
||||||
inactive_branch.is_active = False
|
|
||||||
inactive_branch.save()
|
|
||||||
|
|
||||||
active_branches = self.manager.get_active_branches()
|
|
||||||
self.assertIn(self.main_branch, active_branches)
|
|
||||||
self.assertIn(feature_branch, active_branches)
|
|
||||||
self.assertNotIn(inactive_branch, active_branches)
|
|
||||||
|
|
||||||
def test_get_branch_changes(self):
|
|
||||||
"""Test retrieving changes for a specific branch"""
|
|
||||||
# Create some changes in different branches
|
|
||||||
main_change = ChangeSet.objects.create(
|
|
||||||
branch=self.main_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Main Change'},
|
|
||||||
status='applied'
|
|
||||||
)
|
|
||||||
feature_branch = self.manager.create_branch(name='feature/test')
|
|
||||||
feature_change = ChangeSet.objects.create(
|
|
||||||
branch=feature_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Feature Change'},
|
|
||||||
status='applied'
|
|
||||||
)
|
|
||||||
|
|
||||||
main_changes = self.manager.get_branch_changes(self.main_branch)
|
|
||||||
feature_changes = self.manager.get_branch_changes(feature_branch)
|
|
||||||
|
|
||||||
self.assertIn(main_change, main_changes)
|
|
||||||
self.assertNotIn(feature_change, main_changes)
|
|
||||||
self.assertIn(feature_change, feature_changes)
|
|
||||||
self.assertNotIn(main_change, feature_changes)
|
|
||||||
|
|
||||||
def test_merge_branches(self):
|
|
||||||
"""Test merging changes between branches"""
|
|
||||||
# Create feature branch with changes
|
|
||||||
feature_branch = self.manager.create_branch(name='feature/test')
|
|
||||||
change = ChangeSet.objects.create(
|
|
||||||
branch=feature_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Updated Name'},
|
|
||||||
status='applied'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Merge feature branch into main
|
|
||||||
self.manager.merge_branches(
|
|
||||||
source_branch=feature_branch,
|
|
||||||
target_branch=self.main_branch
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify changes were copied to main branch
|
|
||||||
main_changes = self.manager.get_branch_changes(self.main_branch)
|
|
||||||
self.assertEqual(main_changes.count(), 1)
|
|
||||||
merged_change = main_changes.first()
|
|
||||||
self.assertEqual(merged_change.data, change.data)
|
|
||||||
|
|
||||||
def test_branch_deletion(self):
|
|
||||||
"""Test branch deletion with cleanup"""
|
|
||||||
feature_branch = self.manager.create_branch(name='feature/delete')
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
branch=feature_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Test Change'},
|
|
||||||
status='applied'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete the branch
|
|
||||||
self.manager.delete_branch(feature_branch)
|
|
||||||
|
|
||||||
# Verify branch and its changes are gone
|
|
||||||
with self.assertRaises(VersionBranch.DoesNotExist):
|
|
||||||
VersionBranch.objects.get(name='feature/delete')
|
|
||||||
self.assertEqual(
|
|
||||||
ChangeSet.objects.filter(branch=feature_branch).count(),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
class MergeStrategyTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.park = Park.objects.create(
|
|
||||||
name='Test Park',
|
|
||||||
slug='test-park',
|
|
||||||
status='OPERATING'
|
|
||||||
)
|
|
||||||
self.content_type = ContentType.objects.get_for_model(Park)
|
|
||||||
self.main_branch = VersionBranch.objects.create(
|
|
||||||
name='main',
|
|
||||||
metadata={'type': 'default_branch'}
|
|
||||||
)
|
|
||||||
self.feature_branch = VersionBranch.objects.create(
|
|
||||||
name='feature/test',
|
|
||||||
metadata={'type': 'feature'}
|
|
||||||
)
|
|
||||||
self.merge_strategy = MergeStrategy()
|
|
||||||
|
|
||||||
def test_simple_merge(self):
|
|
||||||
"""Test merging non-conflicting changes"""
|
|
||||||
# Create changes in feature branch
|
|
||||||
feature_changes = [
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
branch=self.feature_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'New Name'},
|
|
||||||
status='applied',
|
|
||||||
applied_at=timezone.now()
|
|
||||||
),
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
branch=self.feature_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'description': 'New Description'},
|
|
||||||
status='applied',
|
|
||||||
applied_at=timezone.now()
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Perform merge
|
|
||||||
with transaction.atomic():
|
|
||||||
conflicts = self.merge_strategy.merge(
|
|
||||||
source_branch=self.feature_branch,
|
|
||||||
target_branch=self.main_branch
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(conflicts, []) # No conflicts expected
|
|
||||||
main_changes = ChangeSet.objects.filter(branch=self.main_branch)
|
|
||||||
self.assertEqual(main_changes.count(), 2)
|
|
||||||
|
|
||||||
def test_conflict_detection(self):
|
|
||||||
"""Test detection of conflicting changes"""
|
|
||||||
# Create conflicting changes
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
branch=self.main_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Main Name'},
|
|
||||||
status='applied',
|
|
||||||
applied_at=timezone.now()
|
|
||||||
)
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
branch=self.feature_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Feature Name'},
|
|
||||||
status='applied',
|
|
||||||
applied_at=timezone.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Attempt merge
|
|
||||||
with transaction.atomic():
|
|
||||||
conflicts = self.merge_strategy.merge(
|
|
||||||
source_branch=self.feature_branch,
|
|
||||||
target_branch=self.main_branch
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertTrue(conflicts) # Conflicts should be detected
|
|
||||||
conflict = conflicts[0]
|
|
||||||
self.assertEqual(conflict['field'], 'name')
|
|
||||||
self.assertEqual(conflict['target_value'], 'Main Name')
|
|
||||||
self.assertEqual(conflict['source_value'], 'Feature Name')
|
|
||||||
|
|
||||||
def test_merge_ordering(self):
|
|
||||||
"""Test that changes are merged in the correct order"""
|
|
||||||
# Create sequential changes
|
|
||||||
change1 = ChangeSet.objects.create(
|
|
||||||
branch=self.feature_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'First Change'},
|
|
||||||
status='applied',
|
|
||||||
applied_at=timezone.now()
|
|
||||||
)
|
|
||||||
change2 = ChangeSet.objects.create(
|
|
||||||
branch=self.feature_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Second Change'},
|
|
||||||
status='applied',
|
|
||||||
applied_at=timezone.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Perform merge
|
|
||||||
with transaction.atomic():
|
|
||||||
self.merge_strategy.merge(
|
|
||||||
source_branch=self.feature_branch,
|
|
||||||
target_branch=self.main_branch
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify changes were merged in order
|
|
||||||
merged_changes = ChangeSet.objects.filter(
|
|
||||||
branch=self.main_branch
|
|
||||||
).order_by('applied_at')
|
|
||||||
self.assertEqual(
|
|
||||||
merged_changes[0].data['name'],
|
|
||||||
'First Change'
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
merged_changes[1].data['name'],
|
|
||||||
'Second Change'
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_merge_validation(self):
|
|
||||||
"""Test validation of merge operations"""
|
|
||||||
# Test merging inactive branch
|
|
||||||
self.feature_branch.is_active = False
|
|
||||||
self.feature_branch.save()
|
|
||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
self.merge_strategy.merge(
|
|
||||||
source_branch=self.feature_branch,
|
|
||||||
target_branch=self.main_branch
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test merging branch into itself
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
self.merge_strategy.merge(
|
|
||||||
source_branch=self.main_branch,
|
|
||||||
target_branch=self.main_branch
|
|
||||||
)
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from history_tracking.models import VersionBranch, ChangeSet
|
|
||||||
from parks.models import Park
|
|
||||||
|
|
||||||
class VersionBranchTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.main_branch = VersionBranch.objects.create(
|
|
||||||
name='main',
|
|
||||||
metadata={'type': 'default_branch'}
|
|
||||||
)
|
|
||||||
self.feature_branch = VersionBranch.objects.create(
|
|
||||||
name='feature/new-layout',
|
|
||||||
metadata={'type': 'feature'}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_branch_creation(self):
|
|
||||||
"""Test that branch creation works with valid data"""
|
|
||||||
branch = VersionBranch.objects.create(
|
|
||||||
name='test-branch',
|
|
||||||
metadata={'type': 'test'}
|
|
||||||
)
|
|
||||||
self.assertEqual(branch.name, 'test-branch')
|
|
||||||
self.assertEqual(branch.metadata['type'], 'test')
|
|
||||||
self.assertTrue(branch.is_active)
|
|
||||||
self.assertIsNotNone(branch.created_at)
|
|
||||||
|
|
||||||
def test_invalid_branch_name(self):
|
|
||||||
"""Test that branch names are properly validated"""
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
VersionBranch.objects.create(name='', metadata={})
|
|
||||||
|
|
||||||
# Test overly long name
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
VersionBranch.objects.create(
|
|
||||||
name='a' * 256,
|
|
||||||
metadata={}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_branch_deactivation(self):
|
|
||||||
"""Test that branches can be deactivated"""
|
|
||||||
self.feature_branch.is_active = False
|
|
||||||
self.feature_branch.save()
|
|
||||||
|
|
||||||
branch = VersionBranch.objects.get(name='feature/new-layout')
|
|
||||||
self.assertFalse(branch.is_active)
|
|
||||||
|
|
||||||
def test_branch_metadata(self):
|
|
||||||
"""Test that branch metadata can be updated"""
|
|
||||||
metadata = {
|
|
||||||
'type': 'feature',
|
|
||||||
'description': 'New layout implementation',
|
|
||||||
'owner': 'test-user'
|
|
||||||
}
|
|
||||||
self.feature_branch.metadata = metadata
|
|
||||||
self.feature_branch.save()
|
|
||||||
|
|
||||||
branch = VersionBranch.objects.get(name='feature/new-layout')
|
|
||||||
self.assertEqual(branch.metadata, metadata)
|
|
||||||
|
|
||||||
class ChangeSetTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.main_branch = VersionBranch.objects.create(
|
|
||||||
name='main',
|
|
||||||
metadata={'type': 'default_branch'}
|
|
||||||
)
|
|
||||||
self.park = Park.objects.create(
|
|
||||||
name='Test Park',
|
|
||||||
slug='test-park',
|
|
||||||
status='OPERATING'
|
|
||||||
)
|
|
||||||
self.content_type = ContentType.objects.get_for_model(Park)
|
|
||||||
|
|
||||||
def test_changeset_creation(self):
|
|
||||||
"""Test that changeset creation works with valid data"""
|
|
||||||
changeset = ChangeSet.objects.create(
|
|
||||||
branch=self.main_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Updated Park Name'},
|
|
||||||
status='pending',
|
|
||||||
description='Update park name'
|
|
||||||
)
|
|
||||||
self.assertEqual(changeset.branch, self.main_branch)
|
|
||||||
self.assertEqual(changeset.content_type, self.content_type)
|
|
||||||
self.assertEqual(changeset.object_id, self.park.id)
|
|
||||||
self.assertEqual(changeset.status, 'pending')
|
|
||||||
|
|
||||||
def test_changeset_status_flow(self):
|
|
||||||
"""Test that changeset status transitions work correctly"""
|
|
||||||
changeset = ChangeSet.objects.create(
|
|
||||||
branch=self.main_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Updated Park Name'},
|
|
||||||
status='pending'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test status transition: pending -> applied
|
|
||||||
changeset.status = 'applied'
|
|
||||||
changeset.applied_at = timezone.now()
|
|
||||||
changeset.save()
|
|
||||||
|
|
||||||
updated_changeset = ChangeSet.objects.get(pk=changeset.pk)
|
|
||||||
self.assertEqual(updated_changeset.status, 'applied')
|
|
||||||
self.assertIsNotNone(updated_changeset.applied_at)
|
|
||||||
|
|
||||||
def test_invalid_changeset_status(self):
|
|
||||||
"""Test that invalid changeset statuses are rejected"""
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
branch=self.main_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Updated Park Name'},
|
|
||||||
status='invalid_status'
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_changeset_validation(self):
|
|
||||||
"""Test that changesets require valid branch and content object"""
|
|
||||||
# Test missing branch
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Updated Park Name'},
|
|
||||||
status='pending'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test invalid content object
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
branch=self.main_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=99999, # Non-existent object
|
|
||||||
data={'name': 'Updated Park Name'},
|
|
||||||
status='pending'
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_changeset_relationship_cascade(self):
|
|
||||||
"""Test that changesets are deleted when branch is deleted"""
|
|
||||||
changeset = ChangeSet.objects.create(
|
|
||||||
branch=self.main_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Updated Park Name'},
|
|
||||||
status='pending'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete the branch
|
|
||||||
self.main_branch.delete()
|
|
||||||
|
|
||||||
# Verify changeset was deleted
|
|
||||||
with self.assertRaises(ChangeSet.DoesNotExist):
|
|
||||||
ChangeSet.objects.get(pk=changeset.pk)
|
|
||||||
|
|
||||||
def test_changeset_data_validation(self):
|
|
||||||
"""Test that changeset data must be valid JSON"""
|
|
||||||
changeset = ChangeSet.objects.create(
|
|
||||||
branch=self.main_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'valid': 'json_data'},
|
|
||||||
status='pending'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Test invalid JSON data
|
|
||||||
with self.assertRaises(ValidationError):
|
|
||||||
changeset.data = "invalid_json"
|
|
||||||
changeset.save()
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
from django.test import TestCase, Client
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.test import override_settings
|
|
||||||
|
|
||||||
from history_tracking.models import VersionBranch, ChangeSet
|
|
||||||
from parks.models import Park
|
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
@override_settings(HTMX_ENABLED=True)
|
|
||||||
class VersionControlViewsTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.client = Client()
|
|
||||||
self.user = User.objects.create_superuser(
|
|
||||||
username='admin',
|
|
||||||
email='admin@example.com',
|
|
||||||
password='testpass123'
|
|
||||||
)
|
|
||||||
self.client.login(username='admin', password='testpass123')
|
|
||||||
|
|
||||||
self.park = Park.objects.create(
|
|
||||||
name='Test Park',
|
|
||||||
slug='test-park',
|
|
||||||
status='OPERATING'
|
|
||||||
)
|
|
||||||
self.content_type = ContentType.objects.get_for_model(Park)
|
|
||||||
|
|
||||||
self.main_branch = VersionBranch.objects.create(
|
|
||||||
name='main',
|
|
||||||
metadata={'type': 'default_branch'}
|
|
||||||
)
|
|
||||||
self.feature_branch = VersionBranch.objects.create(
|
|
||||||
name='feature/test',
|
|
||||||
metadata={'type': 'feature'}
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_version_control_panel(self):
|
|
||||||
"""Test rendering of version control panel"""
|
|
||||||
response = self.client.get(
|
|
||||||
reverse('version_control_panel'),
|
|
||||||
HTTP_HX_REQUEST='true',
|
|
||||||
HTTP_HX_TARGET='version-control-panel'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertTemplateUsed('history_tracking/includes/version_control_ui.html')
|
|
||||||
self.assertContains(response, 'main') # Should show main branch
|
|
||||||
self.assertContains(response, 'feature/test') # Should show feature branch
|
|
||||||
|
|
||||||
def test_create_branch(self):
|
|
||||||
"""Test branch creation through view"""
|
|
||||||
response = self.client.post(
|
|
||||||
reverse('create_branch'),
|
|
||||||
{
|
|
||||||
'name': 'feature/new',
|
|
||||||
'metadata': '{"type": "feature", "description": "New feature"}'
|
|
||||||
},
|
|
||||||
HTTP_HX_REQUEST='true'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertTrue(
|
|
||||||
VersionBranch.objects.filter(name='feature/new').exists()
|
|
||||||
)
|
|
||||||
self.assertContains(response, 'Branch created successfully')
|
|
||||||
|
|
||||||
def test_switch_branch(self):
|
|
||||||
"""Test switching between branches"""
|
|
||||||
response = self.client.post(
|
|
||||||
reverse('switch_branch'),
|
|
||||||
{'branch_id': self.feature_branch.id},
|
|
||||||
HTTP_HX_REQUEST='true'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertContains(response, 'Switched to branch')
|
|
||||||
self.assertContains(response, 'feature/test')
|
|
||||||
|
|
||||||
def test_merge_branch(self):
|
|
||||||
"""Test branch merging through view"""
|
|
||||||
# Create a change in feature branch
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
branch=self.feature_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Updated Name'},
|
|
||||||
status='applied'
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
reverse('merge_branch'),
|
|
||||||
{
|
|
||||||
'source_branch_id': self.feature_branch.id,
|
|
||||||
'target_branch_id': self.main_branch.id
|
|
||||||
},
|
|
||||||
HTTP_HX_REQUEST='true'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertContains(response, 'Branch merged successfully')
|
|
||||||
|
|
||||||
# Verify changes were merged
|
|
||||||
main_changes = ChangeSet.objects.filter(branch=self.main_branch)
|
|
||||||
self.assertEqual(main_changes.count(), 1)
|
|
||||||
|
|
||||||
def test_merge_conflict_handling(self):
|
|
||||||
"""Test handling of merge conflicts"""
|
|
||||||
# Create conflicting changes
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
branch=self.main_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Main Name'},
|
|
||||||
status='applied'
|
|
||||||
)
|
|
||||||
ChangeSet.objects.create(
|
|
||||||
branch=self.feature_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Feature Name'},
|
|
||||||
status='applied'
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
reverse('merge_branch'),
|
|
||||||
{
|
|
||||||
'source_branch_id': self.feature_branch.id,
|
|
||||||
'target_branch_id': self.main_branch.id
|
|
||||||
},
|
|
||||||
HTTP_HX_REQUEST='true'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 409) # Conflict status
|
|
||||||
self.assertContains(response, 'Merge conflicts detected')
|
|
||||||
|
|
||||||
def test_view_history(self):
|
|
||||||
"""Test viewing version history"""
|
|
||||||
# Create some changes
|
|
||||||
change = ChangeSet.objects.create(
|
|
||||||
branch=self.main_branch,
|
|
||||||
content_type=self.content_type,
|
|
||||||
object_id=self.park.id,
|
|
||||||
data={'name': 'Updated Name'},
|
|
||||||
status='applied'
|
|
||||||
)
|
|
||||||
|
|
||||||
response = self.client.get(
|
|
||||||
reverse('version_history', kwargs={'pk': self.park.pk}),
|
|
||||||
HTTP_HX_REQUEST='true'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertContains(response, 'Updated Name')
|
|
||||||
self.assertContains(response, str(change.created_at))
|
|
||||||
|
|
||||||
def test_branch_deletion(self):
|
|
||||||
"""Test branch deletion through view"""
|
|
||||||
response = self.client.post(
|
|
||||||
reverse('delete_branch'),
|
|
||||||
{'branch_id': self.feature_branch.id},
|
|
||||||
HTTP_HX_REQUEST='true'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertContains(response, 'Branch deleted successfully')
|
|
||||||
self.assertFalse(
|
|
||||||
VersionBranch.objects.filter(id=self.feature_branch.id).exists()
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_unauthorized_access(self):
|
|
||||||
"""Test that unauthorized users cannot access version control"""
|
|
||||||
self.client.logout()
|
|
||||||
response = self.client.get(
|
|
||||||
reverse('version_control_panel'),
|
|
||||||
HTTP_HX_REQUEST='true'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 302) # Redirect to login
|
|
||||||
|
|
||||||
def test_htmx_requirements(self):
|
|
||||||
"""Test that views require HTMX headers"""
|
|
||||||
# Try without HTMX headers
|
|
||||||
response = self.client.get(reverse('version_control_panel'))
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
self.assertContains(
|
|
||||||
response,
|
|
||||||
'This endpoint requires HTMX',
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_branch_validation(self):
|
|
||||||
"""Test branch name validation in views"""
|
|
||||||
response = self.client.post(
|
|
||||||
reverse('create_branch'),
|
|
||||||
{
|
|
||||||
'name': 'invalid/branch/name/with/too/many/segments',
|
|
||||||
'metadata': '{}'
|
|
||||||
},
|
|
||||||
HTTP_HX_REQUEST='true'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
self.assertContains(
|
|
||||||
response,
|
|
||||||
'Invalid branch name',
|
|
||||||
status_code=400
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_branch_list_update(self):
|
|
||||||
"""Test that branch list updates after operations"""
|
|
||||||
response = self.client.get(
|
|
||||||
reverse('branch_list'),
|
|
||||||
HTTP_HX_REQUEST='true'
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertContains(response, 'main')
|
|
||||||
self.assertContains(response, 'feature/test')
|
|
||||||
|
|
||||||
# Create new branch
|
|
||||||
new_branch = VersionBranch.objects.create(
|
|
||||||
name='feature/new',
|
|
||||||
metadata={'type': 'feature'}
|
|
||||||
)
|
|
||||||
|
|
||||||
# List should update
|
|
||||||
response = self.client.get(
|
|
||||||
reverse('branch_list'),
|
|
||||||
HTTP_HX_REQUEST='true'
|
|
||||||
)
|
|
||||||
self.assertContains(response, 'feature/new')
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from . import views, htmx_views
|
|
||||||
|
|
||||||
app_name = 'history_tracking'
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
# Main page views
|
|
||||||
path('compare/',
|
|
||||||
views.version_comparison,
|
|
||||||
name='version_comparison'
|
|
||||||
),
|
|
||||||
path('approval-status/<int:changeset_id>/',
|
|
||||||
views.approval_status,
|
|
||||||
name='approval_status'
|
|
||||||
),
|
|
||||||
path('submit-approval/<int:changeset_id>/',
|
|
||||||
views.submit_for_approval,
|
|
||||||
name='submit_for_approval'
|
|
||||||
),
|
|
||||||
|
|
||||||
# HTMX endpoints
|
|
||||||
path('htmx/comments/get/',
|
|
||||||
htmx_views.get_comments,
|
|
||||||
name='get_comments'
|
|
||||||
),
|
|
||||||
path('htmx/comments/preview/',
|
|
||||||
htmx_views.preview_comment,
|
|
||||||
name='preview_comment'
|
|
||||||
),
|
|
||||||
path('htmx/comments/add/',
|
|
||||||
htmx_views.add_comment,
|
|
||||||
name='add_comment'
|
|
||||||
),
|
|
||||||
path('htmx/approve/<int:changeset_id>/',
|
|
||||||
htmx_views.approve_changes,
|
|
||||||
name='approve_changes'
|
|
||||||
),
|
|
||||||
path('htmx/notifications/<int:changeset_id>/',
|
|
||||||
htmx_views.approval_notifications,
|
|
||||||
name='approval_notifications'
|
|
||||||
),
|
|
||||||
path('htmx/comments/replies/<int:comment_id>/',
|
|
||||||
htmx_views.get_replies,
|
|
||||||
name='get_replies'
|
|
||||||
),
|
|
||||||
path('htmx/comments/reply-form/',
|
|
||||||
htmx_views.reply_form,
|
|
||||||
name='reply_form'
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
from typing import Dict, Any, List, Optional, TypeVar, Type, cast
|
|
||||||
from django.core.exceptions import ValidationError
|
|
||||||
from .models import VersionBranch, ChangeSet
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.contrib.auth.models import AbstractUser
|
|
||||||
from django.db.models import Model
|
|
||||||
|
|
||||||
UserModel = TypeVar('UserModel', bound=AbstractUser)
|
|
||||||
User = cast(Type[UserModel], get_user_model())
|
|
||||||
|
|
||||||
def _handle_source_target_resolution(change: ChangeSet) -> Dict[str, Any]:
|
|
||||||
resolved = {}
|
|
||||||
for record in change.historical_records.all():
|
|
||||||
resolved[f"{record.instance_type}_{record.instance_pk}"] = record
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
def _handle_manual_resolution(
|
|
||||||
conflict_id: str,
|
|
||||||
source_change: ChangeSet,
|
|
||||||
manual_resolutions: Dict[str, str],
|
|
||||||
user: Optional[UserModel]
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
manual_content = manual_resolutions.get(conflict_id)
|
|
||||||
if not manual_content:
|
|
||||||
raise ValidationError(f"Manual resolution missing for conflict {conflict_id}")
|
|
||||||
|
|
||||||
resolved = {}
|
|
||||||
base_record = source_change.historical_records.first()
|
|
||||||
if base_record:
|
|
||||||
new_record = base_record.__class__(
|
|
||||||
**{
|
|
||||||
**base_record.__dict__,
|
|
||||||
'id': base_record.id,
|
|
||||||
'history_date': timezone.now(),
|
|
||||||
'history_user': user,
|
|
||||||
'history_change_reason': 'Manual conflict resolution',
|
|
||||||
'history_type': '~'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
for field, value in manual_content.items():
|
|
||||||
setattr(new_record, field, value)
|
|
||||||
resolved[f"{new_record.instance_type}_{new_record.instance_pk}"] = new_record
|
|
||||||
return resolved
|
|
||||||
|
|
||||||
def resolve_conflicts(
|
|
||||||
source_branch: VersionBranch,
|
|
||||||
target_branch: VersionBranch,
|
|
||||||
resolutions: Dict[str, str],
|
|
||||||
manual_resolutions: Dict[str, str],
|
|
||||||
user: Optional[UserModel] = None
|
|
||||||
) -> ChangeSet:
|
|
||||||
"""
|
|
||||||
Resolve merge conflicts between branches
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source_branch: Source branch of the merge
|
|
||||||
target_branch: Target branch of the merge
|
|
||||||
resolutions: Dict mapping conflict IDs to resolution type ('source', 'target', 'manual')
|
|
||||||
manual_resolutions: Dict mapping conflict IDs to manual resolution content
|
|
||||||
user: User performing the resolution
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
ChangeSet: The changeset recording the conflict resolution
|
|
||||||
"""
|
|
||||||
if not resolutions:
|
|
||||||
raise ValidationError("No resolutions provided")
|
|
||||||
|
|
||||||
resolved_content = {}
|
|
||||||
|
|
||||||
for conflict_id, resolution_type in resolutions.items():
|
|
||||||
source_id, target_id = conflict_id.split('_')
|
|
||||||
source_change = ChangeSet.objects.get(pk=source_id)
|
|
||||||
target_change = ChangeSet.objects.get(pk=target_id)
|
|
||||||
|
|
||||||
if resolution_type == 'source':
|
|
||||||
resolved_content.update(_handle_source_target_resolution(source_change))
|
|
||||||
elif resolution_type == 'target':
|
|
||||||
resolved_content.update(_handle_source_target_resolution(target_change))
|
|
||||||
elif resolution_type == 'manual':
|
|
||||||
resolved_content.update(_handle_manual_resolution(
|
|
||||||
conflict_id, source_change, manual_resolutions, user
|
|
||||||
))
|
|
||||||
|
|
||||||
resolution_changeset = ChangeSet.objects.create(
|
|
||||||
branch=target_branch,
|
|
||||||
created_by=user,
|
|
||||||
description=f"Resolved conflicts from '{source_branch.name}'",
|
|
||||||
metadata={
|
|
||||||
'resolution_type': 'conflict_resolution',
|
|
||||||
'source_branch': source_branch.name,
|
|
||||||
'resolved_conflicts': list(resolutions.keys())
|
|
||||||
},
|
|
||||||
status='applied'
|
|
||||||
)
|
|
||||||
|
|
||||||
for record in resolved_content.values():
|
|
||||||
resolution_changeset.historical_records.add(record)
|
|
||||||
|
|
||||||
return resolution_changeset
|
|
||||||
|
|
||||||
def get_change_diff(change: ChangeSet) -> List[Dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Get a structured diff of changes in a changeset
|
|
||||||
|
|
||||||
Args:
|
|
||||||
change: The changeset to analyze
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of diffs for each changed record
|
|
||||||
"""
|
|
||||||
diffs = []
|
|
||||||
|
|
||||||
for record in change.historical_records.all():
|
|
||||||
diff = {
|
|
||||||
'model': record.instance_type.__name__,
|
|
||||||
'id': record.instance_pk,
|
|
||||||
'type': record.history_type,
|
|
||||||
'date': record.history_date,
|
|
||||||
'user': record.history_user_display,
|
|
||||||
'changes': {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if record.history_type == '~': # Modified
|
|
||||||
previous = record.prev_record
|
|
||||||
if previous:
|
|
||||||
diff['changes'] = record.diff_against_previous
|
|
||||||
elif record.history_type == '+': # Added
|
|
||||||
diff['changes'] = {
|
|
||||||
field: {'old': None, 'new': str(getattr(record, field))}
|
|
||||||
for field in record.__dict__
|
|
||||||
if not field.startswith('_') and field not in [
|
|
||||||
'history_date', 'history_id', 'history_type',
|
|
||||||
'history_user_id', 'history_change_reason'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
elif record.history_type == '-': # Deleted
|
|
||||||
diff['changes'] = {
|
|
||||||
field: {'old': str(getattr(record, field)), 'new': None}
|
|
||||||
for field in record.__dict__
|
|
||||||
if not field.startswith('_') and field not in [
|
|
||||||
'history_date', 'history_id', 'history_type',
|
|
||||||
'history_user_id', 'history_change_reason'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
diffs.append(diff)
|
|
||||||
|
|
||||||
return diffs
|
|
||||||
@@ -1,144 +1,3 @@
|
|||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render
|
||||||
from django.contrib.auth.decorators import login_required
|
|
||||||
from django.core.paginator import Paginator
|
|
||||||
from django.db import transaction
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
|
||||||
from django.utils import timezone
|
|
||||||
from django.contrib import messages
|
|
||||||
from django.views.decorators.http import require_http_methods
|
|
||||||
from django.core.exceptions import PermissionDenied
|
|
||||||
from typing import Dict, Any
|
|
||||||
|
|
||||||
from .models import VersionBranch, ChangeSet, VersionTag, HistoricalCommentThread
|
# Create your views here.
|
||||||
from .managers import ChangeTracker
|
|
||||||
from .comparison import ComparisonEngine
|
|
||||||
from .state_machine import ApprovalStateMachine
|
|
||||||
|
|
||||||
ITEMS_PER_PAGE = 20
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def version_comparison(request: HttpRequest) -> HttpResponse:
|
|
||||||
"""View for comparing different versions"""
|
|
||||||
versions = VersionTag.objects.all().order_by('-created_at')
|
|
||||||
|
|
||||||
version1_id = request.GET.get('version1')
|
|
||||||
version2_id = request.GET.get('version2')
|
|
||||||
page_number = request.GET.get('page', 1)
|
|
||||||
|
|
||||||
diff_result = None
|
|
||||||
if version1_id and version2_id:
|
|
||||||
try:
|
|
||||||
version1 = get_object_or_404(VersionTag, id=version1_id)
|
|
||||||
version2 = get_object_or_404(VersionTag, id=version2_id)
|
|
||||||
|
|
||||||
# Get comparison results
|
|
||||||
engine = ComparisonEngine()
|
|
||||||
diff_result = engine.compute_enhanced_diff(version1, version2)
|
|
||||||
|
|
||||||
# Paginate changes
|
|
||||||
paginator = Paginator(diff_result['changes'], ITEMS_PER_PAGE)
|
|
||||||
diff_result['changes'] = paginator.get_page(page_number)
|
|
||||||
|
|
||||||
# Add comments to changes
|
|
||||||
for change in diff_result['changes']:
|
|
||||||
anchor_id = change['metadata']['comment_anchor_id']
|
|
||||||
change['comments'] = HistoricalCommentThread.objects.filter(
|
|
||||||
anchor__contains={'id': anchor_id}
|
|
||||||
).prefetch_related('comments')
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
messages.error(request, f"Error comparing versions: {str(e)}")
|
|
||||||
|
|
||||||
context = {
|
|
||||||
'versions': versions,
|
|
||||||
'selected_version1': version1_id,
|
|
||||||
'selected_version2': version2_id,
|
|
||||||
'diff_result': diff_result
|
|
||||||
}
|
|
||||||
|
|
||||||
return render(request, 'history_tracking/version_comparison.html', context)
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(["POST"])
|
|
||||||
@transaction.atomic
|
|
||||||
def submit_for_approval(request: HttpRequest, changeset_id: int) -> HttpResponse:
|
|
||||||
"""Submit a changeset for approval"""
|
|
||||||
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
|
|
||||||
|
|
||||||
if not request.user.has_perm('history_tracking.submit_for_approval'):
|
|
||||||
raise PermissionDenied("You don't have permission to submit changes for approval")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Initialize approval workflow
|
|
||||||
state_machine = ApprovalStateMachine(changeset)
|
|
||||||
stages_config = [
|
|
||||||
{
|
|
||||||
'name': 'Technical Review',
|
|
||||||
'required_roles': ['tech_reviewer']
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'Final Approval',
|
|
||||||
'required_roles': ['approver']
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
state_machine.initialize_workflow(stages_config)
|
|
||||||
changeset.status = 'pending_approval'
|
|
||||||
changeset.save()
|
|
||||||
|
|
||||||
messages.success(request, "Changes submitted for approval successfully")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
messages.error(request, f"Error submitting for approval: {str(e)}")
|
|
||||||
|
|
||||||
return render(request, 'history_tracking/approval_status.html', {
|
|
||||||
'changeset': changeset
|
|
||||||
})
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
def approval_status(request: HttpRequest, changeset_id: int) -> HttpResponse:
|
|
||||||
"""View approval status of a changeset"""
|
|
||||||
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
|
|
||||||
state_machine = ApprovalStateMachine(changeset)
|
|
||||||
current_stage = state_machine.get_current_stage()
|
|
||||||
|
|
||||||
context = {
|
|
||||||
'changeset': changeset,
|
|
||||||
'current_stage': current_stage,
|
|
||||||
'can_approve': state_machine.can_user_approve(request.user),
|
|
||||||
'pending_approvers': state_machine.get_pending_approvers()
|
|
||||||
}
|
|
||||||
|
|
||||||
return render(request, 'history_tracking/approval_status.html', context)
|
|
||||||
|
|
||||||
@login_required
|
|
||||||
@require_http_methods(["POST"])
|
|
||||||
@transaction.atomic
|
|
||||||
def approve_changes(request: HttpRequest, changeset_id: int) -> HttpResponse:
|
|
||||||
"""Submit an approval decision"""
|
|
||||||
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
|
|
||||||
state_machine = ApprovalStateMachine(changeset)
|
|
||||||
|
|
||||||
try:
|
|
||||||
decision = request.POST.get('decision', 'approve')
|
|
||||||
comment = request.POST.get('comment', '')
|
|
||||||
stage_id = request.POST.get('stage_id')
|
|
||||||
|
|
||||||
success = state_machine.submit_approval(
|
|
||||||
user=request.user,
|
|
||||||
decision=decision,
|
|
||||||
comment=comment,
|
|
||||||
stage_id=stage_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
messages.success(request, f"Successfully {decision}d changes")
|
|
||||||
else:
|
|
||||||
messages.error(request, "Failed to submit approval")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
messages.error(request, f"Error processing approval: {str(e)}")
|
|
||||||
|
|
||||||
return render(request, 'history_tracking/approval_status.html', {
|
|
||||||
'changeset': changeset
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
from django.views.generic import TemplateView
|
|
||||||
from django.contrib.admin.views.decorators import staff_member_required
|
|
||||||
from django.utils.decorators import method_decorator
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from .models import VersionBranch, ChangeSet
|
|
||||||
from .monitoring import VersionControlMetrics
|
|
||||||
|
|
||||||
@method_decorator(staff_member_required, name='dispatch')
|
|
||||||
class MonitoringDashboardView(TemplateView):
|
|
||||||
template_name = 'history_tracking/monitoring_dashboard.html'
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
|
||||||
context = super().get_context_data(**kwargs)
|
|
||||||
metrics = self._collect_metrics()
|
|
||||||
context['metrics'] = metrics
|
|
||||||
return context
|
|
||||||
|
|
||||||
def _collect_metrics(self):
|
|
||||||
"""Collect all monitoring metrics"""
|
|
||||||
# Collect basic statistics
|
|
||||||
total_branches = VersionBranch.objects.count()
|
|
||||||
active_branches = VersionBranch.objects.filter(is_active=True).count()
|
|
||||||
total_changes = ChangeSet.objects.count()
|
|
||||||
pending_changes = ChangeSet.objects.filter(status='pending').count()
|
|
||||||
|
|
||||||
# Calculate merge success rate
|
|
||||||
last_week = timezone.now() - timedelta(days=7)
|
|
||||||
total_merges = ChangeSet.objects.filter(
|
|
||||||
created_at__gte=last_week,
|
|
||||||
status__in=['applied', 'conflict']
|
|
||||||
).count()
|
|
||||||
successful_merges = ChangeSet.objects.filter(
|
|
||||||
created_at__gte=last_week,
|
|
||||||
status='applied'
|
|
||||||
).count()
|
|
||||||
merge_success_rate = round(
|
|
||||||
(successful_merges / total_merges * 100) if total_merges > 0 else 100
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get performance metrics
|
|
||||||
VersionControlMetrics.collect_performance_metrics()
|
|
||||||
perf_metrics = self._get_performance_metrics()
|
|
||||||
|
|
||||||
# Get error tracking data
|
|
||||||
errors = self._get_error_tracking()
|
|
||||||
|
|
||||||
# Get user activity
|
|
||||||
user_activity = self._get_user_activity()
|
|
||||||
|
|
||||||
return {
|
|
||||||
# System Overview
|
|
||||||
'total_branches': total_branches,
|
|
||||||
'active_branches': active_branches,
|
|
||||||
'total_changes': total_changes,
|
|
||||||
'pending_changes': pending_changes,
|
|
||||||
'merge_success_rate': merge_success_rate,
|
|
||||||
'conflicted_merges': ChangeSet.objects.filter(
|
|
||||||
status='conflict'
|
|
||||||
).count(),
|
|
||||||
'system_health': self._calculate_system_health(),
|
|
||||||
'health_checks': 5, # Number of health checks performed
|
|
||||||
|
|
||||||
# Performance Metrics
|
|
||||||
'timing': perf_metrics['timing'],
|
|
||||||
'database': perf_metrics['database'],
|
|
||||||
'cache': perf_metrics['cache'],
|
|
||||||
|
|
||||||
# Error Tracking
|
|
||||||
'errors': errors,
|
|
||||||
|
|
||||||
# User Activity
|
|
||||||
'current_operations': user_activity['current'],
|
|
||||||
'recent_activity': user_activity['recent']
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_performance_metrics(self):
|
|
||||||
"""Get detailed performance metrics"""
|
|
||||||
from django.db import connection
|
|
||||||
from django.core.cache import cache
|
|
||||||
|
|
||||||
# Calculate average operation timings
|
|
||||||
operation_times = {
|
|
||||||
'branch_creation': [],
|
|
||||||
'branch_switch': [],
|
|
||||||
'merge': []
|
|
||||||
}
|
|
||||||
|
|
||||||
for log in self._get_operation_logs():
|
|
||||||
if log['operation'] in operation_times:
|
|
||||||
operation_times[log['operation']].append(log['duration'])
|
|
||||||
|
|
||||||
timing = {
|
|
||||||
op: round(sum(times) / len(times), 2) if times else 0
|
|
||||||
for op, times in operation_times.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
'timing': timing,
|
|
||||||
'database': {
|
|
||||||
'query_count': len(connection.queries),
|
|
||||||
'query_time': round(
|
|
||||||
sum(float(q['time']) for q in connection.queries),
|
|
||||||
3
|
|
||||||
),
|
|
||||||
'pool_size': connection.pool_size if hasattr(connection, 'pool_size') else 'N/A',
|
|
||||||
'max_pool': connection.max_pool if hasattr(connection, 'max_pool') else 'N/A'
|
|
||||||
},
|
|
||||||
'cache': {
|
|
||||||
'hit_rate': round(
|
|
||||||
cache.get('version_control_cache_hits', 0) /
|
|
||||||
(cache.get('version_control_cache_hits', 0) +
|
|
||||||
cache.get('version_control_cache_misses', 1)) * 100,
|
|
||||||
1
|
|
||||||
),
|
|
||||||
'miss_rate': round(
|
|
||||||
cache.get('version_control_cache_misses', 0) /
|
|
||||||
(cache.get('version_control_cache_hits', 0) +
|
|
||||||
cache.get('version_control_cache_misses', 1)) * 100,
|
|
||||||
1
|
|
||||||
),
|
|
||||||
'memory_usage': round(
|
|
||||||
cache.get('version_control_memory_usage', 0) / 1024 / 1024,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_error_tracking(self):
|
|
||||||
"""Get recent error tracking data"""
|
|
||||||
from django.conf import settings
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger('version_control')
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
# Get last 10 error logs
|
|
||||||
if hasattr(logger, 'handlers'):
|
|
||||||
for handler in logger.handlers:
|
|
||||||
if isinstance(handler, logging.FileHandler):
|
|
||||||
try:
|
|
||||||
with open(handler.baseFilename, 'r') as f:
|
|
||||||
for line in f.readlines()[-10:]:
|
|
||||||
if '[ERROR]' in line:
|
|
||||||
errors.append(self._parse_error_log(line))
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return errors
|
|
||||||
|
|
||||||
def _parse_error_log(self, log_line):
|
|
||||||
"""Parse error log line into structured data"""
|
|
||||||
import re
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) \[ERROR\] (.*)'
|
|
||||||
match = re.match(pattern, log_line)
|
|
||||||
|
|
||||||
if match:
|
|
||||||
timestamp_str, message = match.groups()
|
|
||||||
return {
|
|
||||||
'timestamp': datetime.strptime(
|
|
||||||
timestamp_str,
|
|
||||||
'%Y-%m-%d %H:%M:%S,%f'
|
|
||||||
),
|
|
||||||
'type': 'Error',
|
|
||||||
'operation': self._extract_operation(message),
|
|
||||||
'message': message,
|
|
||||||
'resolved': False
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _extract_operation(self, message):
|
|
||||||
"""Extract operation type from error message"""
|
|
||||||
if 'branch' in message.lower():
|
|
||||||
return 'Branch Operation'
|
|
||||||
elif 'merge' in message.lower():
|
|
||||||
return 'Merge Operation'
|
|
||||||
elif 'changeset' in message.lower():
|
|
||||||
return 'Change Operation'
|
|
||||||
return 'Unknown Operation'
|
|
||||||
|
|
||||||
def _get_user_activity(self):
|
|
||||||
"""Get current and recent user activity"""
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
# Get active sessions
|
|
||||||
from django.contrib.sessions.models import Session
|
|
||||||
current_sessions = Session.objects.filter(
|
|
||||||
expire_date__gte=timezone.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
current_operations = []
|
|
||||||
for session in current_sessions:
|
|
||||||
try:
|
|
||||||
uid = session.get_decoded().get('_auth_user_id')
|
|
||||||
if uid:
|
|
||||||
user = User.objects.get(pk=uid)
|
|
||||||
current_operations.append({
|
|
||||||
'user': user.username,
|
|
||||||
'action': self._get_user_current_action(user)
|
|
||||||
})
|
|
||||||
except (User.DoesNotExist, KeyError):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get recent activity
|
|
||||||
recent = ChangeSet.objects.select_related('user').order_by(
|
|
||||||
'-created_at'
|
|
||||||
)[:10]
|
|
||||||
recent_activity = [
|
|
||||||
{
|
|
||||||
'user': change.user.username if change.user else 'System',
|
|
||||||
'action': self._get_change_action(change),
|
|
||||||
'timestamp': change.created_at
|
|
||||||
}
|
|
||||||
for change in recent
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
'current': current_operations,
|
|
||||||
'recent': recent_activity
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_user_current_action(self, user):
|
|
||||||
"""Get user's current action based on recent activity"""
|
|
||||||
last_change = ChangeSet.objects.filter(
|
|
||||||
user=user
|
|
||||||
).order_by('-created_at').first()
|
|
||||||
|
|
||||||
if last_change:
|
|
||||||
if (timezone.now() - last_change.created_at).seconds < 300: # 5 minutes
|
|
||||||
return self._get_change_action(last_change)
|
|
||||||
return 'Viewing'
|
|
||||||
|
|
||||||
def _get_change_action(self, change):
|
|
||||||
"""Get human-readable action from change"""
|
|
||||||
if change.status == 'applied':
|
|
||||||
return f'Applied changes to {change.content_object}'
|
|
||||||
elif change.status == 'pending':
|
|
||||||
return f'Started editing {change.content_object}'
|
|
||||||
elif change.status == 'conflict':
|
|
||||||
return f'Resolving conflicts on {change.content_object}'
|
|
||||||
return 'Unknown action'
|
|
||||||
|
|
||||||
def _calculate_system_health(self):
|
|
||||||
"""Calculate overall system health percentage"""
|
|
||||||
factors = {
|
|
||||||
'merge_success': self._get_merge_success_health(),
|
|
||||||
'performance': self._get_performance_health(),
|
|
||||||
'error_rate': self._get_error_rate_health()
|
|
||||||
}
|
|
||||||
return round(sum(factors.values()) / len(factors))
|
|
||||||
|
|
||||||
def _get_merge_success_health(self):
|
|
||||||
"""Calculate health based on merge success rate"""
|
|
||||||
last_week = timezone.now() - timedelta(days=7)
|
|
||||||
total_merges = ChangeSet.objects.filter(
|
|
||||||
created_at__gte=last_week,
|
|
||||||
status__in=['applied', 'conflict']
|
|
||||||
).count()
|
|
||||||
successful_merges = ChangeSet.objects.filter(
|
|
||||||
created_at__gte=last_week,
|
|
||||||
status='applied'
|
|
||||||
).count()
|
|
||||||
|
|
||||||
if total_merges == 0:
|
|
||||||
return 100
|
|
||||||
return round((successful_merges / total_merges) * 100)
|
|
||||||
|
|
||||||
def _get_performance_health(self):
|
|
||||||
"""Calculate health based on performance metrics"""
|
|
||||||
metrics = self._get_performance_metrics()
|
|
||||||
|
|
||||||
factors = [
|
|
||||||
100 if metrics['timing']['merge'] < 1000 else 50, # Under 1 second is healthy
|
|
||||||
100 if metrics['cache']['hit_rate'] > 80 else 50, # Over 80% cache hit rate is healthy
|
|
||||||
100 if metrics['database']['query_time'] < 0.5 else 50 # Under 0.5s query time is healthy
|
|
||||||
]
|
|
||||||
|
|
||||||
return round(sum(factors) / len(factors))
|
|
||||||
|
|
||||||
def _get_error_rate_health(self):
|
|
||||||
"""Calculate health based on error rate"""
|
|
||||||
last_day = timezone.now() - timedelta(days=1)
|
|
||||||
total_operations = ChangeSet.objects.filter(
|
|
||||||
created_at__gte=last_day
|
|
||||||
).count()
|
|
||||||
error_count = len([
|
|
||||||
e for e in self._get_error_tracking()
|
|
||||||
if e['timestamp'] >= last_day
|
|
||||||
])
|
|
||||||
|
|
||||||
if total_operations == 0:
|
|
||||||
return 100
|
|
||||||
error_rate = (error_count / total_operations) * 100
|
|
||||||
return round(100 - error_rate)
|
|
||||||
|
|
||||||
def _get_operation_logs(self):
|
|
||||||
"""Get operation timing logs"""
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
log_file = Path('logs/version_control_timing.log')
|
|
||||||
if not log_file.exists():
|
|
||||||
return []
|
|
||||||
|
|
||||||
logs = []
|
|
||||||
try:
|
|
||||||
with open(log_file, 'r') as f:
|
|
||||||
for line in f:
|
|
||||||
try:
|
|
||||||
logs.append(json.loads(line))
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
return []
|
|
||||||
|
|
||||||
return logs
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
from typing import Optional, Tuple
|
|
||||||
|
|
||||||
class LocationMixin:
|
|
||||||
"""Mixin for models that can have location data attached."""
|
|
||||||
|
|
||||||
def get_location(self) -> Optional['Location']:
|
|
||||||
"""Get location for this instance."""
|
|
||||||
from location.models import Location
|
|
||||||
ct = ContentType.objects.get_for_model(self.__class__)
|
|
||||||
return Location.objects.filter(content_type=ct, object_id=self.pk).first()
|
|
||||||
|
|
||||||
def set_location(self, address: str, latitude: float, longitude: float) -> 'Location':
|
|
||||||
"""Set or update location for this instance."""
|
|
||||||
from location.models import Location
|
|
||||||
ct = ContentType.objects.get_for_model(self.__class__)
|
|
||||||
location, created = Location.objects.update_or_create(
|
|
||||||
content_type=ct,
|
|
||||||
object_id=self.pk,
|
|
||||||
defaults={
|
|
||||||
'address': address,
|
|
||||||
'latitude': latitude,
|
|
||||||
'longitude': longitude
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return location
|
|
||||||
|
|
||||||
@property
|
|
||||||
def coordinates(self) -> Optional[Tuple[float, float]]:
|
|
||||||
"""Get coordinates (latitude, longitude) if available."""
|
|
||||||
location = self.get_location()
|
|
||||||
if location:
|
|
||||||
return location.latitude, location.longitude
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def formatted_location(self) -> str:
|
|
||||||
"""Get formatted address string if available."""
|
|
||||||
location = self.get_location()
|
|
||||||
if location:
|
|
||||||
return location.get_formatted_address()
|
|
||||||
return ""
|
|
||||||
@@ -1,28 +1,19 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.contenttypes.admin import GenericStackedInline
|
from django.utils.html import format_html
|
||||||
from .models import Photo
|
from .models import Photo
|
||||||
|
|
||||||
class PhotoInline(GenericStackedInline):
|
|
||||||
"""Inline admin for photos that can be added to any model."""
|
|
||||||
model = Photo
|
|
||||||
extra = 1
|
|
||||||
fields = ('image', 'caption', 'alt_text', 'is_primary')
|
|
||||||
classes = ('collapse',)
|
|
||||||
|
|
||||||
@admin.register(Photo)
|
@admin.register(Photo)
|
||||||
class PhotoAdmin(admin.ModelAdmin):
|
class PhotoAdmin(admin.ModelAdmin):
|
||||||
list_display = ('caption', 'content_type', 'object_id', 'is_primary', 'created_at')
|
list_display = ('thumbnail_preview', 'content_type', 'content_object', 'caption', 'is_primary', 'created_at')
|
||||||
list_filter = ('content_type', 'created_at', 'is_primary', 'is_approved')
|
list_filter = ('content_type', 'is_primary', 'created_at')
|
||||||
search_fields = ('caption', 'alt_text')
|
search_fields = ('caption', 'alt_text')
|
||||||
ordering = ('content_type', 'object_id', '-is_primary')
|
readonly_fields = ('thumbnail_preview',)
|
||||||
readonly_fields = ('created_at', 'updated_at')
|
|
||||||
|
|
||||||
fieldsets = (
|
def thumbnail_preview(self, obj):
|
||||||
('Image', {
|
if obj.image:
|
||||||
'fields': ('image', 'caption', 'alt_text', 'is_primary', 'is_approved')
|
return format_html(
|
||||||
}),
|
'<img src="{}" style="max-height: 50px; max-width: 100px;" />',
|
||||||
('Metadata', {
|
obj.image.url
|
||||||
'fields': ('content_type', 'object_id', 'created_at', 'updated_at'),
|
)
|
||||||
'classes': ('collapse',)
|
return "No image"
|
||||||
}),
|
thumbnail_preview.short_description = 'Thumbnail'
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
|
|
||||||
class PhotoableModel:
|
|
||||||
"""Mixin for models that can have photos attached."""
|
|
||||||
|
|
||||||
def get_photos(self) -> QuerySet:
|
|
||||||
"""Get photos for this instance."""
|
|
||||||
from media.models import Photo
|
|
||||||
ct = ContentType.objects.get_for_model(self.__class__)
|
|
||||||
return Photo.objects.filter(content_type=ct, object_id=self.pk)
|
|
||||||
|
|
||||||
def add_photo(self, photo: 'Photo') -> None:
|
|
||||||
"""Add a photo to this instance."""
|
|
||||||
from media.models import Photo
|
|
||||||
ct = ContentType.objects.get_for_model(self.__class__)
|
|
||||||
photo.content_type = ct
|
|
||||||
photo.object_id = self.pk
|
|
||||||
photo.save()
|
|
||||||
@@ -1,74 +1,146 @@
|
|||||||
# Comment System Architecture Fix
|
# Active Context
|
||||||
|
|
||||||
## Required Code Modifications
|
## Current Project State
|
||||||
|
|
||||||
### 1. Central CommentThread Model (comments/models.py)
|
### Active Components
|
||||||
```python
|
- Django backend with core apps
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
- accounts
|
||||||
from django.contrib.contenttypes.models import ContentType
|
- analytics
|
||||||
from django.db import models
|
- companies
|
||||||
|
- core
|
||||||
|
- designers
|
||||||
|
- email_service
|
||||||
|
- history_tracking
|
||||||
|
- location
|
||||||
|
- media
|
||||||
|
- moderation
|
||||||
|
- parks
|
||||||
|
- reviews
|
||||||
|
- rides
|
||||||
|
|
||||||
class CommentThread(models.Model):
|
### Implementation Status
|
||||||
"""Centralized comment threading system"""
|
1. Backend Framework
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
- ✅ Django setup
|
||||||
object_id = models.PositiveIntegerField()
|
- ✅ Database models
|
||||||
content_object = GenericForeignKey()
|
- ✅ Authentication system
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
- ✅ Admin interface
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
2. Frontend Integration
|
||||||
indexes = [
|
- ✅ HTMX integration
|
||||||
models.Index(fields=["content_type", "object_id"]),
|
- ✅ AlpineJS setup
|
||||||
]
|
- ✅ Tailwind CSS configuration
|
||||||
app_label = 'comments'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Model Reference Updates (Example for companies/models.py)
|
3. Core Features
|
||||||
```python
|
- ✅ User authentication
|
||||||
# In all affected models (companies, rides, parks, reviews):
|
- ✅ Park management
|
||||||
from comments.models import CommentThread
|
- ✅ Ride tracking
|
||||||
|
- ✅ Review system
|
||||||
|
- ✅ Location services
|
||||||
|
- ✅ Media handling
|
||||||
|
|
||||||
class Company(models.Model):
|
## Current Focus Areas
|
||||||
# ... existing fields ...
|
|
||||||
comments = GenericRelation(CommentThread) # Updated reference
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Historical Records Adjustment
|
### Active Development
|
||||||
```python
|
1. Content Management
|
||||||
# Historical model definitions:
|
- Moderation workflow refinement
|
||||||
class HistoricalCompany(HistoricalRecords):
|
- Content quality metrics
|
||||||
comments = models.ForeignKey(
|
- User contribution tracking
|
||||||
'comments.CommentThread', # Unified reference
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Migration Execution Plan
|
2. User Experience
|
||||||
|
- Frontend performance optimization
|
||||||
|
- UI/UX improvements
|
||||||
|
- Responsive design enhancements
|
||||||
|
|
||||||
1. Generate initial comment thread migration:
|
3. System Reliability
|
||||||
```bash
|
- Error handling improvements
|
||||||
./manage.py makemigrations comments --name create_commentthread
|
- Testing coverage
|
||||||
```
|
- Performance monitoring
|
||||||
|
|
||||||
2. Create dependent migrations for each modified app:
|
## Immediate Next Steps
|
||||||
```bash
|
|
||||||
for app in companies rides parks reviews; do
|
|
||||||
./manage.py makemigrations $app --name update_comment_references
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Migration dependency chain:
|
### Technical Tasks
|
||||||
```python
|
1. Testing
|
||||||
# In each app's migration file:
|
- [ ] Increase test coverage
|
||||||
dependencies = [
|
- [ ] Implement integration tests
|
||||||
('comments', '0001_create_commentthread'),
|
- [ ] Add performance tests
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation Checklist
|
2. Documentation
|
||||||
- [ ] Run full test suite: `uv test ./manage.py test`
|
- [ ] Complete API documentation
|
||||||
- [ ] Execute system check: `uv run ./manage.py check --deploy`
|
- [ ] Update setup guides
|
||||||
- [ ] Verify database schema changes in migration files
|
- [ ] Document common workflows
|
||||||
- [ ] Confirm admin interface comment relationships
|
|
||||||
|
3. Performance
|
||||||
|
- [ ] Optimize database queries
|
||||||
|
- [ ] Implement caching strategy
|
||||||
|
- [ ] Improve asset loading
|
||||||
|
|
||||||
|
### Feature Development
|
||||||
|
1. Content Quality
|
||||||
|
- [ ] Enhanced moderation tools
|
||||||
|
- [ ] Automated content checks
|
||||||
|
- [ ] Media optimization
|
||||||
|
|
||||||
|
2. User Features
|
||||||
|
- [ ] Profile enhancements
|
||||||
|
- [ ] Contribution tracking
|
||||||
|
- [ ] Notification system
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
1. Performance
|
||||||
|
- Query optimization needed for large datasets
|
||||||
|
- Caching implementation incomplete
|
||||||
|
|
||||||
|
2. Technical Debt
|
||||||
|
- Some views need refactoring
|
||||||
|
- Test coverage gaps
|
||||||
|
- Documentation updates needed
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
1. UI/UX
|
||||||
|
- Mobile responsiveness improvements
|
||||||
|
- Loading state refinements
|
||||||
|
- 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
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Historical Model Comment Fixes
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
System check errors occurred because historical models referenced CommentThread in their own app context (e.g. `companies.commentthread`) instead of the actual `comments.CommentThread` model.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
Added `excluded_fields = ['comments']` to Meta classes of all affected models to exclude comment relationships from historical tracking. Note: Initially tried `history_exclude` but this was incorrect - django-simple-history uses `excluded_fields`.
|
|
||||||
|
|
||||||
## Affected Models (Fixed)
|
|
||||||
- Company (companies/models.py)
|
|
||||||
- Manufacturer (companies/models.py)
|
|
||||||
- Designer (companies/models.py)
|
|
||||||
- Park (parks/models.py)
|
|
||||||
- ParkArea (parks/models.py)
|
|
||||||
- Ride (rides/models.py)
|
|
||||||
- RideModel (rides/models.py)
|
|
||||||
- Review (reviews/models.py)
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
Each model's Meta class was updated to exclude the comments field from historical tracking:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class Meta:
|
|
||||||
# ... other Meta options ...
|
|
||||||
excluded_fields = ['comments'] # Exclude from historical tracking
|
|
||||||
```
|
|
||||||
|
|
||||||
This prevents django-simple-history from attempting to track the GenericRelation field in historical models, which was causing the system check errors.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
Run system checks to verify fix:
|
|
||||||
```bash
|
|
||||||
python manage.py check
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
# Version Control System Evaluation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Comprehensive evaluation of the project's version control implementation conducted on 2025-02-07.
|
|
||||||
|
|
||||||
## Core Architecture Assessment
|
|
||||||
|
|
||||||
### Strengths
|
|
||||||
- Well-structured modular design with clear separation of concerns
|
|
||||||
- Robust history tracking using Django's HistoricalRecords
|
|
||||||
- Comprehensive branch and changeset management
|
|
||||||
- Built-in comment threading and review system
|
|
||||||
- Strong monitoring and metrics collection
|
|
||||||
|
|
||||||
### Data Model Design
|
|
||||||
|
|
||||||
#### Core Models
|
|
||||||
- `HistoricalModel` (Abstract base)
|
|
||||||
- `VersionBranch` (Branch management)
|
|
||||||
- `VersionTag` (Version tagging)
|
|
||||||
- `ChangeSet` (Atomic changes)
|
|
||||||
- `CommentThread` & `Comment` (Review system)
|
|
||||||
|
|
||||||
#### Relationships
|
|
||||||
✅ Properly structured relationships between models
|
|
||||||
✅ Effective use of GenericForeignKey for flexibility
|
|
||||||
✅ Clear handling of model history
|
|
||||||
|
|
||||||
## Implementation Analysis
|
|
||||||
|
|
||||||
### Version Control Features
|
|
||||||
1. Branching System
|
|
||||||
- ✅ Branch hierarchy with parent-child relationships
|
|
||||||
- ✅ Branch metadata and activity tracking
|
|
||||||
- ✅ Lock management for concurrent access
|
|
||||||
|
|
||||||
2. Change Tracking
|
|
||||||
- ✅ Atomic changesets with approval workflow
|
|
||||||
- ✅ Detailed change metadata
|
|
||||||
- ✅ Dependency tracking
|
|
||||||
- ✅ Revert capabilities
|
|
||||||
|
|
||||||
3. Review System
|
|
||||||
- ✅ Threaded comments with mentions
|
|
||||||
- ✅ Line-specific annotations
|
|
||||||
- ✅ Resolution tracking
|
|
||||||
|
|
||||||
### Monitoring & Performance
|
|
||||||
- Comprehensive metrics collection
|
|
||||||
- Performance tracking for operations
|
|
||||||
- Database query monitoring
|
|
||||||
- Cache performance tracking
|
|
||||||
- Structured logging with Sentry integration
|
|
||||||
|
|
||||||
## Areas for Improvement
|
|
||||||
|
|
||||||
### 1. Performance Optimizations
|
|
||||||
- Consider implementing batch processing for large changesets
|
|
||||||
- Add caching for frequently accessed version history
|
|
||||||
- Optimize query patterns for large history sets
|
|
||||||
|
|
||||||
### 2. Feature Enhancements
|
|
||||||
- Add support for cherry-picking changes between branches
|
|
||||||
- Implement automated conflict resolution for simple cases
|
|
||||||
- Add hooks system for custom version control events
|
|
||||||
|
|
||||||
### 3. Scalability Considerations
|
|
||||||
- Implement archive strategy for old history records
|
|
||||||
- Add partitioning support for large history tables
|
|
||||||
- Consider async processing for heavy operations
|
|
||||||
|
|
||||||
### 4. Maintenance Recommendations
|
|
||||||
- Implement automated cleanup for orphaned records
|
|
||||||
- Add integrity checks for version history
|
|
||||||
- Enhance monitoring with custom alerts
|
|
||||||
|
|
||||||
## Security Assessment
|
|
||||||
- ✅ Proper access control in place
|
|
||||||
- ✅ Branch locking mechanism
|
|
||||||
- ✅ Audit trail for all operations
|
|
||||||
- 🔄 Consider adding encryption for sensitive changes
|
|
||||||
|
|
||||||
## Integration Points
|
|
||||||
- Well-integrated with Django's ORM
|
|
||||||
- Clean API endpoints for version control operations
|
|
||||||
- Frontend integration through structured responses
|
|
||||||
- Monitoring integration with external services
|
|
||||||
|
|
||||||
## Recommendations
|
|
||||||
|
|
||||||
### Short Term
|
|
||||||
1. Implement batch processing for large changesets
|
|
||||||
2. Add caching layer for version history
|
|
||||||
3. Create automated cleanup procedures
|
|
||||||
|
|
||||||
### Medium Term
|
|
||||||
1. Develop cherry-picking functionality
|
|
||||||
2. Implement automated conflict resolution
|
|
||||||
3. Add versioning hooks system
|
|
||||||
|
|
||||||
### Long Term
|
|
||||||
1. Implement archiving strategy
|
|
||||||
2. Add partitioning support
|
|
||||||
3. Enhance async processing capabilities
|
|
||||||
|
|
||||||
## Maintainability
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- ✅ Well-documented API
|
|
||||||
- ✅ Comprehensive user guide
|
|
||||||
- ✅ Clear technical documentation
|
|
||||||
- 🔄 Consider adding more code examples
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- ✅ Unit tests present
|
|
||||||
- ✅ Integration testing
|
|
||||||
- 🔄 Add more performance tests
|
|
||||||
- 🔄 Enhance stress testing
|
|
||||||
|
|
||||||
## Final Assessment
|
|
||||||
The version control system is well-implemented with robust features and good maintainability. While there are areas for improvement, the core functionality is solid and provides a strong foundation for future enhancements.
|
|
||||||
|
|
||||||
Overall Rating: ⭐⭐⭐⭐☆ (4/5)
|
|
||||||
@@ -57,16 +57,6 @@
|
|||||||
- 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
|
||||||
@@ -76,9 +66,6 @@
|
|||||||
- 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+
|
||||||
@@ -86,17 +73,6 @@
|
|||||||
- 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
|
||||||
@@ -126,3 +102,14 @@
|
|||||||
- 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)
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
# Version Control Feature
|
|
||||||
|
|
||||||
## Strategic Overview
|
|
||||||
|
|
||||||
### Purpose
|
|
||||||
The version control system provides comprehensive content versioning, branching, and merging capabilities across ThrillWiki's models, enabling parallel content development and safe experimentation.
|
|
||||||
|
|
||||||
### Key Decisions
|
|
||||||
|
|
||||||
#### 1. Infrastructure Integration
|
|
||||||
- **Decision**: Leverage existing Django database and Redis infrastructure
|
|
||||||
- **Rationale**:
|
|
||||||
- Reduces operational complexity
|
|
||||||
- Maintains consistent data storage patterns
|
|
||||||
- Utilizes existing backup and monitoring systems
|
|
||||||
- **Impact**: Simplified deployment and maintenance
|
|
||||||
|
|
||||||
#### 2. Architecture Pattern
|
|
||||||
- **Decision**: Implement as a Django app (history_tracking)
|
|
||||||
- **Rationale**:
|
|
||||||
- Follows Django's modular architecture
|
|
||||||
- Enables easy integration with other apps
|
|
||||||
- Maintains consistent development patterns
|
|
||||||
- **Impact**: Clean separation of concerns and reusability
|
|
||||||
|
|
||||||
#### 3. Performance Strategy
|
|
||||||
- **Decision**: Built-in batch processing and caching
|
|
||||||
- **Rationale**:
|
|
||||||
- Handles large-scale content changes efficiently
|
|
||||||
- Optimizes frequently accessed version history
|
|
||||||
- Reduces database load
|
|
||||||
- **Impact**: Scales well with growing content and user base
|
|
||||||
|
|
||||||
### Technical Integration
|
|
||||||
|
|
||||||
#### Database Layer
|
|
||||||
- Uses existing PostgreSQL database
|
|
||||||
- Creates dedicated version control tables
|
|
||||||
- Integrates with Django's ORM
|
|
||||||
- Maintains data consistency through transactions
|
|
||||||
|
|
||||||
#### Caching Layer
|
|
||||||
- Uses existing Redis infrastructure
|
|
||||||
- Dedicated cache prefixes (vc_*)
|
|
||||||
- Configurable cache durations
|
|
||||||
- Automatic cache invalidation
|
|
||||||
|
|
||||||
#### Application Layer
|
|
||||||
- Modular Django app design
|
|
||||||
- HTMX integration for UI updates
|
|
||||||
- AlpineJS for client-side interactions
|
|
||||||
- Tailwind CSS for styling
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
1. Models
|
|
||||||
- HistoricalModel (base class)
|
|
||||||
- VersionBranch (branch management)
|
|
||||||
- ChangeSet (atomic changes)
|
|
||||||
- CommentThread (review system)
|
|
||||||
|
|
||||||
2. Features
|
|
||||||
- Branch management
|
|
||||||
- Change tracking
|
|
||||||
- Merge operations
|
|
||||||
- Review system
|
|
||||||
- Performance monitoring
|
|
||||||
|
|
||||||
3. Integration Points
|
|
||||||
- Model versioning
|
|
||||||
- Template components
|
|
||||||
- API endpoints
|
|
||||||
- Admin interface
|
|
||||||
|
|
||||||
### Usage Patterns
|
|
||||||
|
|
||||||
#### Model Integration
|
|
||||||
```python
|
|
||||||
class YourModel(HistoricalModel):
|
|
||||||
# Automatic version control capabilities
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Branch Management
|
|
||||||
```python
|
|
||||||
with branch_context(branch):
|
|
||||||
# Changes tracked in specific branch
|
|
||||||
model.save()
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Batch Operations
|
|
||||||
```python
|
|
||||||
with BatchOperation() as batch:
|
|
||||||
# Efficient handling of multiple changes
|
|
||||||
batch.process_changes(changes)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Guidelines
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
1. Use batch operations for multiple changes
|
|
||||||
2. Implement proper branch management
|
|
||||||
3. Handle merge conflicts explicitly
|
|
||||||
4. Monitor performance metrics
|
|
||||||
5. Cache frequently accessed data
|
|
||||||
|
|
||||||
### Anti-Patterns to Avoid
|
|
||||||
1. Direct model changes outside branch context
|
|
||||||
2. Inefficient querying of version history
|
|
||||||
3. Ignoring batch operations for bulk changes
|
|
||||||
4. Manual cache management
|
|
||||||
|
|
||||||
## Monitoring and Maintenance
|
|
||||||
|
|
||||||
### Performance Monitoring
|
|
||||||
- Operation timing metrics
|
|
||||||
- Cache hit rates
|
|
||||||
- Database query patterns
|
|
||||||
- Memory usage
|
|
||||||
- API response times
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
- Branch integrity
|
|
||||||
- Cache consistency
|
|
||||||
- Database indexes
|
|
||||||
- Query performance
|
|
||||||
- System resources
|
|
||||||
|
|
||||||
## Future Considerations
|
|
||||||
|
|
||||||
### Planned Enhancements
|
|
||||||
1. Advanced conflict resolution
|
|
||||||
2. Enhanced performance monitoring
|
|
||||||
3. Additional caching strategies
|
|
||||||
4. Improved UI components
|
|
||||||
|
|
||||||
### Scalability Path
|
|
||||||
1. Partition strategies for large histories
|
|
||||||
2. Advanced caching patterns
|
|
||||||
3. Async operation handling
|
|
||||||
4. Archive management
|
|
||||||
|
|
||||||
## Documentation Map
|
|
||||||
|
|
||||||
### Technical Documentation
|
|
||||||
- Implementation Guide: `history_tracking/README.md`
|
|
||||||
- API Documentation: `docs/version_control_api.md`
|
|
||||||
- User Guide: `docs/version_control_user_guide.md`
|
|
||||||
|
|
||||||
### Architecture Documentation
|
|
||||||
- Technical Context: `memory-bank/techContext.md`
|
|
||||||
- System Patterns: `memory-bank/systemPatterns.md`
|
|
||||||
- Evaluation Report: `memory-bank/evaluations/version_control_evaluation.md`
|
|
||||||
|
|
||||||
## Support and Maintenance
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
1. Cache invalidation
|
|
||||||
2. Merge conflicts
|
|
||||||
3. Performance optimization
|
|
||||||
4. Data consistency
|
|
||||||
|
|
||||||
### Resolution Steps
|
|
||||||
1. Monitor system metrics
|
|
||||||
2. Review error logs
|
|
||||||
3. Check cache status
|
|
||||||
4. Verify database integrity
|
|
||||||
|
|
||||||
## Integration Status
|
|
||||||
✅ Database Integration
|
|
||||||
✅ Redis Configuration
|
|
||||||
✅ Model Integration
|
|
||||||
✅ UI Components
|
|
||||||
✅ API Endpoints
|
|
||||||
✅ Documentation
|
|
||||||
✅ Monitoring Setup
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Change Approval Workflow Implementation Plan
|
|
||||||
|
|
||||||
## Core Requirements
|
|
||||||
1. Configurable approval stages
|
|
||||||
2. Role-based reviewer assignments
|
|
||||||
3. Parallel vs sequential approvals
|
|
||||||
4. Audit trail of decisions
|
|
||||||
5. Integration with existing locks/comments
|
|
||||||
|
|
||||||
## Technical Integration
|
|
||||||
- **State Machine**
|
|
||||||
Extend StateMachine interface:
|
|
||||||
```typescript
|
|
||||||
interface ApprovalStateMachine extends StateMachine {
|
|
||||||
currentStage: ApprovalStage;
|
|
||||||
requiredApprovers: UserRef[];
|
|
||||||
overridePolicy: 'majority' | 'unanimous';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Model Extensions**
|
|
||||||
Enhance ChangeSet (line 7):
|
|
||||||
```python
|
|
||||||
class ChangeSet(models.Model):
|
|
||||||
approval_state = models.JSONField(default=list) # [{stage: 1, approvers: [...]}]
|
|
||||||
approval_history = models.JSONField(default=list)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **API Endpoints**
|
|
||||||
Add to VersionControlViewSet (line 128):
|
|
||||||
```python
|
|
||||||
@action(detail=True, methods=['post'])
|
|
||||||
def submit_for_approval(self, request, pk=None):
|
|
||||||
"""Transition change set to approval state"""
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
- Approval chain validation
|
|
||||||
- Non-repudiation requirements
|
|
||||||
- Conflict resolution protocols
|
|
||||||
- Approval delegation safeguards
|
|
||||||
|
|
||||||
## Phase Plan
|
|
||||||
1. **Week 1**: State machine implementation
|
|
||||||
2. **Week 2**: Approval UI components
|
|
||||||
3. **Week 3**: Integration testing
|
|
||||||
4. **Week 4**: Deployment safeguards
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
# Branch Locking System Implementation Plan
|
|
||||||
|
|
||||||
## Core Requirements
|
|
||||||
1. Role-based locking permissions
|
|
||||||
2. Lock state indicators in UI
|
|
||||||
3. Lock override protocols
|
|
||||||
4. Audit logging for lock events
|
|
||||||
5. Maximum lock duration: 48hrs
|
|
||||||
|
|
||||||
## Technical Integration
|
|
||||||
- **Model Extensions**
|
|
||||||
Enhance `VersionBranch` (line 14):
|
|
||||||
```python
|
|
||||||
class VersionBranch(models.Model):
|
|
||||||
lock_status = models.JSONField(default=dict) # {user: ID, expires: datetime}
|
|
||||||
lock_history = models.JSONField(default=list)
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Manager Methods**
|
|
||||||
Add to `BranchManager` (line 141):
|
|
||||||
```python
|
|
||||||
def acquire_lock(self, branch, user, duration=48):
|
|
||||||
"""Implements lock with timeout"""
|
|
||||||
|
|
||||||
def release_lock(self, branch, force=False):
|
|
||||||
"""Handles lock release with permission checks"""
|
|
||||||
```
|
|
||||||
|
|
||||||
- **UI Components**
|
|
||||||
Update `VersionControlUI` interface (line 58):
|
|
||||||
```typescript
|
|
||||||
lockState: {
|
|
||||||
isLocked: boolean;
|
|
||||||
lockedBy: UserRef;
|
|
||||||
expiresAt: Date;
|
|
||||||
canOverride: boolean;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
- Permission escalation prevention
|
|
||||||
- Lock expiration enforcement
|
|
||||||
- Audit log integrity checks
|
|
||||||
- Session validation for lock holders
|
|
||||||
|
|
||||||
## Phase Plan
|
|
||||||
1. **Week 1**: Locking backend implementation
|
|
||||||
2. **Week 2**: Permission system integration
|
|
||||||
3. **Week 3**: UI indicators & controls
|
|
||||||
4. **Week 4**: Audit system & testing
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
# Change Commenting System Implementation Plan
|
|
||||||
|
|
||||||
## Core Requirements
|
|
||||||
1. Threaded comment conversations
|
|
||||||
2. @mention functionality
|
|
||||||
3. File/line anchoring
|
|
||||||
4. Notification system
|
|
||||||
5. Comment resolution tracking
|
|
||||||
|
|
||||||
## Technical Integration
|
|
||||||
- **Model Relationships**
|
|
||||||
Extend `HistoricalRecord` (line 31):
|
|
||||||
```python
|
|
||||||
class HistoricalRecord(models.Model):
|
|
||||||
comments = GenericRelation('CommentThread') # Enables change comments
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Collaboration System**
|
|
||||||
Enhance interface (line 85):
|
|
||||||
```typescript
|
|
||||||
interface CollaborationSystem {
|
|
||||||
createCommentThread(
|
|
||||||
changeId: string,
|
|
||||||
anchor: LineRange,
|
|
||||||
initialComment: string
|
|
||||||
): Promise<CommentThread>;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **UI Components**
|
|
||||||
New `InlineCommentPanel` component:
|
|
||||||
```typescript
|
|
||||||
interface CommentProps {
|
|
||||||
thread: CommentThread;
|
|
||||||
canResolve: boolean;
|
|
||||||
onReply: (content: string) => void;
|
|
||||||
onResolve: () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notification Matrix
|
|
||||||
| Event Type | Notification Channel | Escalation Path |
|
|
||||||
|------------|----------------------|-----------------|
|
|
||||||
| New comment | In-app, Email | After 24hrs → Slack DM |
|
|
||||||
| @mention | Mobile push, Email | After 12hrs → SMS |
|
|
||||||
| Resolution | In-app | None |
|
|
||||||
|
|
||||||
## Phase Plan
|
|
||||||
1. **Week 1**: Comment storage infrastructure
|
|
||||||
2. **Week 2**: Anchoring system & UI
|
|
||||||
3. **Week 3**: Notification pipeline
|
|
||||||
4. **Week 4**: Moderation tools & audit
|
|
||||||
@@ -1,292 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
## Critical Implementation Revisions
|
|
||||||
|
|
||||||
### Phase 1.1: Core Model Updates (2 Days)
|
|
||||||
1. Add lock fields to VersionBranch
|
|
||||||
2. Implement StateMachine base class
|
|
||||||
3. Extend HistoricalChangeMixin with structured diffs
|
|
||||||
|
|
||||||
### Phase 2.1: Manager Classes (3 Days)
|
|
||||||
```python
|
|
||||||
class LockManager(models.Manager):
|
|
||||||
def get_locked_branches(self):
|
|
||||||
return self.filter(lock_status__isnull=False)
|
|
||||||
|
|
||||||
class StateMachine:
|
|
||||||
def __init__(self, workflow):
|
|
||||||
self.states = workflow['states']
|
|
||||||
self.transitions = workflow['transitions']
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 3.1: Security Backports (1 Day)
|
|
||||||
- Add model clean() validation
|
|
||||||
- Implement permission check decorators
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# Version Control System Implementation Checklist
|
|
||||||
|
|
||||||
## Core Implementation ✓
|
|
||||||
- [x] Models
|
|
||||||
- [x] VersionBranch
|
|
||||||
- [x] VersionTag
|
|
||||||
- [x] ChangeSet
|
|
||||||
- [x] Generic relationships for flexibility
|
|
||||||
|
|
||||||
- [x] Managers
|
|
||||||
- [x] BranchManager
|
|
||||||
- [x] ChangeTracker
|
|
||||||
- [x] MergeStrategy
|
|
||||||
|
|
||||||
- [x] UI Components
|
|
||||||
- [x] Version Control Panel
|
|
||||||
- [x] Branch List
|
|
||||||
- [x] History View
|
|
||||||
- [x] Merge Panel
|
|
||||||
- [x] Branch Creation Form
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
- [ ] Add visual diff viewer - [See Visual Diff Viewer Plan](visual-diff-viewer.md)
|
|
||||||
- [ ] Implement branch locking - [See Branch Locking System](branch-locking.md)
|
|
||||||
- [ ] Add commenting on changes - [See Change Comments Framework](change-comments.md)
|
|
||||||
- [ ] Create change approval workflow - [See Approval Workflow Docs](approval-workflow.md)
|
|
||||||
- [ ] Add version comparison tool - [See Comparison Tool Spec](version-comparison.md)
|
|
||||||
|
|
||||||
## Documentation Updates ✓
|
|
||||||
- [x] README creation
|
|
||||||
- [x] Implementation guide
|
|
||||||
- [x] Template integration guide
|
|
||||||
- [x] API documentation
|
|
||||||
- [x] User guide
|
|
||||||
|
|
||||||
## Testing Requirements ✓
|
|
||||||
- [x] Unit Tests
|
|
||||||
- [x] Integration Tests
|
|
||||||
- [x] UI Tests
|
|
||||||
|
|
||||||
## Monitoring Setup ✓
|
|
||||||
- [x] Performance Metrics
|
|
||||||
- [x] Error Tracking
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Version Control Feature Integration Matrix
|
|
||||||
|
|
||||||
| Feature | Depends On | Provides To | Shared Components |
|
|
||||||
|---------|------------|-------------|-------------------|
|
|
||||||
| Visual Diff Viewer | Version Comparison | Branch Locking | DiffEngine, LineMapper |
|
|
||||||
| Branch Locking | Approval Workflow | Change Comments | LockManager, AuditLogger |
|
|
||||||
| Change Comments | Visual Diff Viewer | Approval Workflow | CommentStore, @MentionService |
|
|
||||||
| Approval Workflow | Branch Locking | Version Comparison | StateMachine, Notifier |
|
|
||||||
| Version Comparison | All Features | - | TimelineRenderer, DiffAnalyzer |
|
|
||||||
|
|
||||||
## Critical Integration Points
|
|
||||||
- Lock status visibility in diff viewer (Line 14 ↔ Line 58)
|
|
||||||
- Comment threads in approval decisions (Line 31 ↔ Line 85)
|
|
||||||
- Comparison metadata for rollback safety (Line 6 ↔ Line 128)
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
# Version Control UI Template Integration
|
|
||||||
|
|
||||||
## Templates Requiring VCS Integration
|
|
||||||
|
|
||||||
### Park System
|
|
||||||
- [x] parks/templates/parks/park_detail.html - Completed
|
|
||||||
- [ ] parks/templates/parks/park_list.html - Add version status indicators
|
|
||||||
- [ ] parks/templates/parks/park_area_detail.html - Add version control UI
|
|
||||||
|
|
||||||
### Rides System
|
|
||||||
- [ ] rides/templates/rides/ride_detail.html - Add version control UI
|
|
||||||
- [ ] rides/templates/rides/ride_list.html - Add version status indicators
|
|
||||||
|
|
||||||
### Reviews System
|
|
||||||
- [ ] reviews/templates/reviews/review_detail.html - Add version control UI
|
|
||||||
- [ ] reviews/templates/reviews/review_list.html - Add version status indicators
|
|
||||||
|
|
||||||
### Company System
|
|
||||||
- [ ] companies/templates/companies/company_detail.html - Add version control UI
|
|
||||||
- [ ] companies/templates/companies/company_list.html - Add version status indicators
|
|
||||||
|
|
||||||
## Integration Guidelines
|
|
||||||
|
|
||||||
### Detail Templates
|
|
||||||
For detail templates, add the version control UI below the main title:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!-- Title Section -->
|
|
||||||
<h1>{{ object.name }}</h1>
|
|
||||||
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<!-- Rest of the content -->
|
|
||||||
```
|
|
||||||
|
|
||||||
### List Templates
|
|
||||||
For list templates, add version indicators in the list items:
|
|
||||||
|
|
||||||
```html
|
|
||||||
{% for item in object_list %}
|
|
||||||
<div class="item">
|
|
||||||
<h2>{{ item.name }}</h2>
|
|
||||||
{% if version_control.vcs_enabled %}
|
|
||||||
<div class="version-info text-sm text-gray-600">
|
|
||||||
Branch: {{ item.get_version_info.current_branch.name }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration Steps
|
|
||||||
|
|
||||||
1. Update base template to include necessary JavaScript
|
|
||||||
```html
|
|
||||||
<!-- In base.html -->
|
|
||||||
<script src="{% static 'js/version-control.js' %}"></script>
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Add version control UI to detail views
|
|
||||||
- Include the version control UI component
|
|
||||||
- Add branch switching functionality
|
|
||||||
- Display version history
|
|
||||||
|
|
||||||
3. Add version indicators to list views
|
|
||||||
- Show current branch
|
|
||||||
- Indicate if changes are pending
|
|
||||||
- Show version status
|
|
||||||
|
|
||||||
4. Update view classes
|
|
||||||
- Ensure models inherit from HistoricalModel
|
|
||||||
- Add version control context
|
|
||||||
- Handle branch switching
|
|
||||||
|
|
||||||
5. Test integration
|
|
||||||
- Verify UI appears correctly
|
|
||||||
- Test branch switching
|
|
||||||
- Verify history tracking
|
|
||||||
- Test merge functionality
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
1. Create park area detail template with version control
|
|
||||||
2. Update ride detail template
|
|
||||||
3. Add version control to review system
|
|
||||||
4. Integrate with company templates
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Version Control System Type Fixes
|
|
||||||
|
|
||||||
## Completed Fixes
|
|
||||||
|
|
||||||
### 1. managers.py ✓
|
|
||||||
- Added proper UserModel TypeVar
|
|
||||||
- Fixed type hints for User references
|
|
||||||
- Added missing type imports
|
|
||||||
- Improved type safety in method signatures
|
|
||||||
|
|
||||||
### 2. utils.py ✓
|
|
||||||
- Updated User type hints
|
|
||||||
- Consistent use of UserModel TypeVar
|
|
||||||
- Fixed return type annotations
|
|
||||||
- Added proper type imports
|
|
||||||
|
|
||||||
## Remaining Checks
|
|
||||||
|
|
||||||
### 1. models.py
|
|
||||||
- [ ] Check User related fields
|
|
||||||
- [ ] Verify ForeignKey type hints
|
|
||||||
- [ ] Review manager annotations
|
|
||||||
- [ ] Check metaclass type hints
|
|
||||||
|
|
||||||
### 2. views.py
|
|
||||||
- [ ] Verify request.user type hints
|
|
||||||
- [ ] Check class-based view type hints
|
|
||||||
- [ ] Review context type hints
|
|
||||||
- [ ] Check form handling types
|
|
||||||
|
|
||||||
### 3. signals.py
|
|
||||||
- [ ] Check signal receiver type hints
|
|
||||||
- [ ] Verify sender type annotations
|
|
||||||
- [ ] Review instance type hints
|
|
||||||
- [ ] Check User type usage
|
|
||||||
|
|
||||||
### 4. context_processors.py
|
|
||||||
- [ ] Verify request type hints
|
|
||||||
- [ ] Check context dictionary types
|
|
||||||
- [ ] Review User type usage
|
|
||||||
|
|
||||||
## Type Safety Guidelines
|
|
||||||
|
|
||||||
1. User Type Pattern:
|
|
||||||
```python
|
|
||||||
UserModel = TypeVar('UserModel', bound=AbstractUser)
|
|
||||||
User = cast(Type[UserModel], get_user_model())
|
|
||||||
|
|
||||||
def my_function(user: Optional[UserModel] = None) -> Any:
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Model References:
|
|
||||||
```python
|
|
||||||
from django.db.models import Model, QuerySet
|
|
||||||
from typing import Type, TypeVar
|
|
||||||
|
|
||||||
T = TypeVar('T', bound=Model)
|
|
||||||
|
|
||||||
def get_model(model_class: Type[T]) -> QuerySet[T]:
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Generic Views:
|
|
||||||
```python
|
|
||||||
from typing import TypeVar, Generic
|
|
||||||
from django.views.generic import DetailView
|
|
||||||
|
|
||||||
T = TypeVar('T', bound=Model)
|
|
||||||
|
|
||||||
class MyDetailView(DetailView, Generic[T]):
|
|
||||||
model: Type[T]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Audit Remaining Files:
|
|
||||||
- Review all files for type hint consistency
|
|
||||||
- Update any deprecated type hint syntax
|
|
||||||
- Add missing type hints where needed
|
|
||||||
|
|
||||||
2. Type Testing:
|
|
||||||
- Run mypy checks
|
|
||||||
- Verify Pylance reports
|
|
||||||
- Test with strict type checking
|
|
||||||
|
|
||||||
3. Documentation:
|
|
||||||
- Document type patterns used
|
|
||||||
- Update technical guide with type hints
|
|
||||||
- Add type checking to contribution guide
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# Version Control System UI Improvements
|
|
||||||
|
|
||||||
## Recent Improvements
|
|
||||||
|
|
||||||
### 1. Template Structure Enhancement
|
|
||||||
- Moved map initialization to dedicated JavaScript file
|
|
||||||
- Implemented data attribute pattern for passing data to JavaScript
|
|
||||||
- Improved template organization and maintainability
|
|
||||||
|
|
||||||
### 2. JavaScript Organization
|
|
||||||
- Created separate `map-init.js` for map functionality
|
|
||||||
- Established pattern for external JavaScript files
|
|
||||||
- Improved error handling and script loading
|
|
||||||
|
|
||||||
### 3. Asset Management
|
|
||||||
```javascript
|
|
||||||
// Static Asset Organization
|
|
||||||
/static/
|
|
||||||
/js/
|
|
||||||
version-control.js // Core VCS functionality
|
|
||||||
map-init.js // Map initialization logic
|
|
||||||
/css/
|
|
||||||
version-control.css // VCS styles
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices Established
|
|
||||||
|
|
||||||
### 1. Data Passing Pattern
|
|
||||||
```html
|
|
||||||
<!-- Using data attributes for JavaScript configuration -->
|
|
||||||
<div id="map"
|
|
||||||
data-lat="{{ coordinates.lat }}"
|
|
||||||
data-lng="{{ coordinates.lng }}"
|
|
||||||
data-name="{{ name }}">
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. JavaScript Separation
|
|
||||||
```javascript
|
|
||||||
// Modular JavaScript organization
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Initialize components
|
|
||||||
const mapContainer = document.getElementById('map');
|
|
||||||
if (mapContainer) {
|
|
||||||
// Component-specific logic
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Template Structure
|
|
||||||
```html
|
|
||||||
{% block content %}
|
|
||||||
<!-- Main content -->
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
{{ block.super }}
|
|
||||||
<!-- Component-specific scripts -->
|
|
||||||
<script src="{% static 'js/component-script.js' %}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration Guidelines
|
|
||||||
|
|
||||||
### 1. Adding New Components
|
|
||||||
1. Create dedicated JavaScript file in `/static/js/`
|
|
||||||
2. Use data attributes for configuration
|
|
||||||
3. Follow established loading pattern
|
|
||||||
4. Update base template if needed
|
|
||||||
|
|
||||||
### 2. Version Control UI
|
|
||||||
1. Include version control UI component
|
|
||||||
2. Add necessary data attributes
|
|
||||||
3. Ensure proper script loading
|
|
||||||
4. Follow established patterns
|
|
||||||
|
|
||||||
### 3. Static Asset Management
|
|
||||||
1. Keep JavaScript files modular
|
|
||||||
2. Use proper static file organization
|
|
||||||
3. Follow naming conventions
|
|
||||||
4. Maintain clear dependencies
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Apply this pattern to other templates:
|
|
||||||
- Ride detail template
|
|
||||||
- Review detail template
|
|
||||||
- Company detail template
|
|
||||||
|
|
||||||
2. Implement consistent error handling:
|
|
||||||
```javascript
|
|
||||||
function handleError(error) {
|
|
||||||
console.error('Component error:', error);
|
|
||||||
// Handle error appropriately
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Add performance monitoring:
|
|
||||||
```javascript
|
|
||||||
// Add timing measurements
|
|
||||||
const startTime = performance.now();
|
|
||||||
// Component initialization
|
|
||||||
const endTime = performance.now();
|
|
||||||
console.debug(`Component initialized in ${endTime - startTime}ms`);
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Documentation updates:
|
|
||||||
- Add JavaScript patterns to technical guide
|
|
||||||
- Update template integration guide
|
|
||||||
- Document asset organization
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# Version Comparison Tool Implementation Plan
|
|
||||||
|
|
||||||
## Core Requirements
|
|
||||||
1. Multi-version timeline visualization
|
|
||||||
2. Three-way merge preview
|
|
||||||
3. Change impact analysis
|
|
||||||
4. Rollback capabilities
|
|
||||||
5. Performance baseline: <500ms for 100-file diffs
|
|
||||||
|
|
||||||
## Technical Integration
|
|
||||||
- **Diff Algorithm**
|
|
||||||
Enhance visual-diff-viewer.md component (line 10):
|
|
||||||
```typescript
|
|
||||||
interface ComparisonEngine {
|
|
||||||
compareVersions(versions: string[]): StructuredDiff[];
|
|
||||||
calculateImpactScore(diffs: StructuredDiff[]): number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Model Extensions**
|
|
||||||
Update VersionTag (line 6):
|
|
||||||
```python
|
|
||||||
class VersionTag(models.Model):
|
|
||||||
comparison_metadata = models.JSONField(default=dict) # Stores diff stats
|
|
||||||
```
|
|
||||||
|
|
||||||
- **API Endpoints**
|
|
||||||
Add to VersionControlViewSet (line 128):
|
|
||||||
```python
|
|
||||||
@action(detail=False, methods=['post'])
|
|
||||||
def bulk_compare(self, request):
|
|
||||||
"""Process multi-version comparisons"""
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Strategy
|
|
||||||
| Aspect | Solution | Target |
|
|
||||||
|--------|----------|--------|
|
|
||||||
| Diff computation | Background workers | 90% async processing |
|
|
||||||
| Result caching | Redis cache layer | 5min TTL |
|
|
||||||
| Large files | Chunked processing | 10MB chunks |
|
|
||||||
| UI rendering | Virtualized scrolling | 60fps maintain |
|
|
||||||
|
|
||||||
## Phase Plan
|
|
||||||
1. **Week 1**: Core comparison algorithm
|
|
||||||
2. **Week 2**: Timeline visualization UI
|
|
||||||
3. **Week 3**: Performance optimization
|
|
||||||
4. **Week 4**: Rollback safety mechanisms
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Visual Diff Viewer Implementation Plan
|
|
||||||
|
|
||||||
## Core Requirements
|
|
||||||
1. Side-by-side comparison interface
|
|
||||||
2. Syntax highlighting for code diffs
|
|
||||||
3. Inline comment anchoring
|
|
||||||
4. Change navigation controls
|
|
||||||
5. Performance budget: 200ms render time
|
|
||||||
|
|
||||||
## Technical Integration
|
|
||||||
- **Frontend**
|
|
||||||
Extend `DiffViewer` component (line 62) with:
|
|
||||||
```typescript
|
|
||||||
interface EnhancedDiffViewer {
|
|
||||||
renderStrategy: 'inline' | 'side-by-side';
|
|
||||||
syntaxHighlighters: Map<string, Highlighter>;
|
|
||||||
commentThreads: CommentThread[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Backend**
|
|
||||||
Enhance `ChangeTracker.compute_diff()` (line 156):
|
|
||||||
```python
|
|
||||||
def compute_enhanced_diff(self, version1, version2):
|
|
||||||
"""Return structured diff with syntax metadata"""
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependency Matrix
|
|
||||||
| Component | Affected Lines | Modification Type |
|
|
||||||
|-----------|----------------|--------------------|
|
|
||||||
| HistoricalChangeMixin | Current impl. line 6 | Extension |
|
|
||||||
| CollaborationSystem | line 90 | Event handling |
|
|
||||||
| VersionControlUI | line 62 | Props update |
|
|
||||||
|
|
||||||
## Phase Plan
|
|
||||||
1. **Week 1**: Diff algorithm optimization
|
|
||||||
2. **Week 2**: UI component development
|
|
||||||
3. **Week 3**: Performance testing
|
|
||||||
4. **Week 4**: Security review
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# Version Control Security Audit Checklist
|
|
||||||
|
|
||||||
## Core Security Domains
|
|
||||||
1. **Authentication**
|
|
||||||
- [ ] MFA required for lock overrides (Branch Locking.md Line 58)
|
|
||||||
- [ ] Session invalidation on permission changes
|
|
||||||
|
|
||||||
2. **Authorization**
|
|
||||||
- [ ] Role hierarchy enforcement (Approval Workflow.md Line 22)
|
|
||||||
- [ ] Context-sensitive permission checks
|
|
||||||
|
|
||||||
3. **Data Protection**
|
|
||||||
- [ ] Encryption of comparison metadata (Version Comparison.md Line 6)
|
|
||||||
- [ ] Audit log integrity verification
|
|
||||||
|
|
||||||
4. **Workflow Security**
|
|
||||||
- [ ] State machine tamper detection (Approval Workflow.md Line 45)
|
|
||||||
- [ ] Comment edit history immutability
|
|
||||||
|
|
||||||
## Threat Mitigation Table
|
|
||||||
| Threat Type | Affected Feature | Mitigation Strategy |
|
|
||||||
|-------------|------------------|---------------------|
|
|
||||||
| Race Conditions | Branch Locking | Optimistic locking with version stamps |
|
|
||||||
| XSS | Change Comments | DOMPurify integration (Line 89) |
|
|
||||||
| Data Leakage | Version Comparison | Strict field-level encryption |
|
|
||||||
| Repudiation | Approval Workflow | Blockchain-style audit trail |
|
|
||||||
|
|
||||||
## Testing Procedures
|
|
||||||
1. **Penetration Tests**
|
|
||||||
- Lock bypass attempts via API fuzzing
|
|
||||||
- Approval state injection attacks
|
|
||||||
|
|
||||||
2. **Static Analysis**
|
|
||||||
- OWASP ZAP scan configuration
|
|
||||||
- SonarQube security rule activation
|
|
||||||
|
|
||||||
3. **Runtime Monitoring**
|
|
||||||
- Unauthorized diff access alerts
|
|
||||||
- Abnormal approval pattern detection
|
|
||||||
|
|
||||||
## Phase Integration
|
|
||||||
| Development Phase | Security Focus |
|
|
||||||
|--------------------|----------------|
|
|
||||||
| Locking Implementation | Permission model validation |
|
|
||||||
| Workflow Development | State transition auditing |
|
|
||||||
| Comment System | Content sanitization checks |
|
|
||||||
| Comparison Tool | Data anonymization tests |
|
|
||||||
|
|
||||||
## Severity Levels
|
|
||||||
- **Critical**: Direct system access vulnerabilities
|
|
||||||
- **High**: Data integrity risks
|
|
||||||
- **Medium**: UX security weaknesses
|
|
||||||
- **Low**: Informational exposure
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# OWASP Top 10 Compliance Mapping
|
|
||||||
|
|
||||||
| OWASP Item | Our Implementation | Verification Method |
|
|
||||||
|------------|--------------------|---------------------|
|
|
||||||
| A01:2021-Broken Access Control | Branch Locking permissions (Line 58) | Penetration testing |
|
|
||||||
| A03:2021-Injection | Comment sanitization (Line 89) | Static code analysis |
|
|
||||||
| A05:2021-Security Misconfiguration | Version Tag defaults (Line 6) | Configuration audits |
|
|
||||||
| A08:2021-Software/Data Integrity Failures | Audit logging (Checklist 3.4) | Checksum verification |
|
|
||||||
|
|
||||||
## Critical Compliance Gaps
|
|
||||||
1. Cryptographic failures (Data at rest encryption) - Scheduled for Phase 3
|
|
||||||
2. Server-side request forgery - Requires API gateway hardening
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
# Security Test Case Template
|
|
||||||
|
|
||||||
## Authentication Tests
|
|
||||||
```gherkin
|
|
||||||
Scenario: Lock override with expired session
|
|
||||||
Given an active branch lock
|
|
||||||
When session expires during override attempt
|
|
||||||
Then system should reject with 401 Unauthorized
|
|
||||||
And log security event "LOCK_OVERRIDE_FAILURE"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Injection Prevention
|
|
||||||
```gherkin
|
|
||||||
Scenario: XSS in change comments
|
|
||||||
When submitting comment with <script>alert(1)</script>
|
|
||||||
Then response should sanitize to "&lt;script&gt;alert(1)&lt;/script&gt;"
|
|
||||||
And store original input in quarantine
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Integrity
|
|
||||||
```gherkin
|
|
||||||
Scenario: Unauthorized diff modification
|
|
||||||
Given approved version comparison
|
|
||||||
When altering historical diff metadata
|
|
||||||
Then checksum validation should fail
|
|
||||||
And trigger auto-rollback procedure
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Security
|
|
||||||
```gherkin
|
|
||||||
Scenario: Approval state bypass
|
|
||||||
Given pending approval workflow
|
|
||||||
When attempting direct state transition
|
|
||||||
Then enforce state machine rules
|
|
||||||
And log "ILLEGAL_STATE_CHANGE" event
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring Tests
|
|
||||||
```gherkin
|
|
||||||
Scenario: Abnormal approval patterns
|
|
||||||
Given 10 rapid approvals from same IP
|
|
||||||
When monitoring system detects anomaly
|
|
||||||
Then freeze approval process
|
|
||||||
And notify security team
|
|
||||||
@@ -21,72 +21,6 @@
|
|||||||
- Implement component-based structure
|
- Implement component-based structure
|
||||||
- Follow progressive enhancement
|
- Follow progressive enhancement
|
||||||
|
|
||||||
## Version Control Patterns
|
|
||||||
|
|
||||||
### Change Management
|
|
||||||
1. Batch Processing
|
|
||||||
```python
|
|
||||||
class BatchChangeProcessor:
|
|
||||||
def process_changes(self, changes, chunk_size=100):
|
|
||||||
"""Process changes in efficient batches"""
|
|
||||||
with transaction.atomic():
|
|
||||||
for chunk in chunked_queryset(changes, chunk_size):
|
|
||||||
self._process_chunk(chunk)
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Caching Strategy
|
|
||||||
```python
|
|
||||||
class VersionCache:
|
|
||||||
def cache_history(self, instance):
|
|
||||||
"""Cache version history with TTL"""
|
|
||||||
key = f"version_history_{instance.pk}"
|
|
||||||
if not cache.get(key):
|
|
||||||
history = instance.get_history()
|
|
||||||
cache.set(key, history, timeout=3600)
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Change Tracking
|
|
||||||
```python
|
|
||||||
class ChangeTracker:
|
|
||||||
def track_changes(self, instance):
|
|
||||||
"""Track changes with metadata"""
|
|
||||||
return {
|
|
||||||
'changes': self._diff_changes(instance),
|
|
||||||
'metadata': self._collect_metadata(),
|
|
||||||
'performance': self._get_metrics()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance Optimization
|
|
||||||
|
|
||||||
1. Query Patterns
|
|
||||||
```python
|
|
||||||
class HistoryQuerySet:
|
|
||||||
def optimized_history(self):
|
|
||||||
"""Optimized history query"""
|
|
||||||
return self.select_related('branch')\
|
|
||||||
.prefetch_related('changes')\
|
|
||||||
.defer('large_fields')
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Async Operations
|
|
||||||
```python
|
|
||||||
class AsyncVersionControl:
|
|
||||||
async def process_large_changes(self):
|
|
||||||
"""Handle large changes asynchronously"""
|
|
||||||
async with atomic():
|
|
||||||
# Async processing logic
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Archiving Strategy
|
|
||||||
```python
|
|
||||||
class HistoryArchiver:
|
|
||||||
def archive_old_versions(self, age_days=90):
|
|
||||||
"""Archive old version history"""
|
|
||||||
threshold = timezone.now() - timedelta(days=age_days)
|
|
||||||
return self._move_to_archive(threshold)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Design Patterns
|
## Design Patterns
|
||||||
|
|
||||||
### Data Access
|
### Data Access
|
||||||
@@ -101,8 +35,6 @@
|
|||||||
- Implement model-level caching
|
- Implement model-level caching
|
||||||
- Use Redis for session storage
|
- Use Redis for session storage
|
||||||
- Cache invalidation rules
|
- Cache invalidation rules
|
||||||
- Version history caching
|
|
||||||
- Differential caching for changes
|
|
||||||
|
|
||||||
### Frontend Patterns
|
### Frontend Patterns
|
||||||
|
|
||||||
@@ -130,35 +62,6 @@
|
|||||||
</div>
|
</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Version Control UI Patterns
|
|
||||||
|
|
||||||
1. Change Visualization
|
|
||||||
```html
|
|
||||||
<!-- Diff View Pattern -->
|
|
||||||
<div class="diff-view"
|
|
||||||
x-data="diffViewer"
|
|
||||||
x-init="loadDiff()">
|
|
||||||
<div class="diff-header"></div>
|
|
||||||
<div class="diff-content"></div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Branch Management
|
|
||||||
```html
|
|
||||||
<!-- Branch Selector Pattern -->
|
|
||||||
<div class="branch-selector"
|
|
||||||
x-data="branchManager"
|
|
||||||
@branch-changed="updateContent()">
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Merge Resolution
|
|
||||||
```html
|
|
||||||
<!-- Conflict Resolution Pattern -->
|
|
||||||
<div class="conflict-resolver"
|
|
||||||
x-data="conflictResolver"
|
|
||||||
@resolve="handleResolution()">
|
|
||||||
```
|
|
||||||
|
|
||||||
## Authentication Patterns
|
## Authentication Patterns
|
||||||
|
|
||||||
### User Management
|
### User Management
|
||||||
@@ -220,25 +123,14 @@
|
|||||||
|
|
||||||
## Testing Patterns
|
## Testing Patterns
|
||||||
|
|
||||||
### Performance Testing
|
### Unit Tests
|
||||||
```python
|
```python
|
||||||
class VersionControlPerformanceTests(TestCase):
|
class ModelTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.large_dataset = self.create_test_data()
|
# Test setup
|
||||||
|
|
||||||
def test_batch_processing_performance(self):
|
def test_specific_functionality(self):
|
||||||
start_time = time.time()
|
# Test implementation
|
||||||
self.processor.process_changes(self.large_dataset)
|
|
||||||
duration = time.time() - start_time
|
|
||||||
self.assertLess(duration, self.acceptable_threshold)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scale Testing
|
|
||||||
```python
|
|
||||||
class ScaleTestCase(TestCase):
|
|
||||||
def test_version_history_scaling(self):
|
|
||||||
with self.assertNumQueries(1): # Ensure efficient querying
|
|
||||||
self.repository.get_history()
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
@@ -271,9 +163,3 @@ class ViewTests(TestCase):
|
|||||||
- Testing verification
|
- Testing verification
|
||||||
- Documentation update
|
- Documentation update
|
||||||
- Deployment planning
|
- Deployment planning
|
||||||
|
|
||||||
4. Performance Review
|
|
||||||
- Query analysis
|
|
||||||
- Cache efficiency
|
|
||||||
- Load testing
|
|
||||||
- Scalability verification
|
|
||||||
@@ -5,8 +5,7 @@
|
|||||||
### Stack Components
|
### Stack Components
|
||||||
- **Framework**: Django (MVT Architecture)
|
- **Framework**: Django (MVT Architecture)
|
||||||
- **Frontend**: HTMX + AlpineJS + Tailwind CSS
|
- **Frontend**: HTMX + AlpineJS + Tailwind CSS
|
||||||
- **Database**: PostgreSQL with Django ORM
|
- **Database**: Django ORM
|
||||||
- **Cache**: Redis for application and version control
|
|
||||||
- **Authentication**: Django Built-in Auth
|
- **Authentication**: Django Built-in Auth
|
||||||
|
|
||||||
## Technical Architecture
|
## Technical Architecture
|
||||||
@@ -26,15 +25,6 @@
|
|||||||
- Validation rules
|
- Validation rules
|
||||||
- Signal handlers
|
- Signal handlers
|
||||||
- Database migrations
|
- Database migrations
|
||||||
- Version control tracking
|
|
||||||
|
|
||||||
3. Version Control System
|
|
||||||
- Branching and merging capabilities
|
|
||||||
- Change tracking with history
|
|
||||||
- Batch processing operations
|
|
||||||
- Caching strategy using Redis
|
|
||||||
- Performance monitoring
|
|
||||||
- Multi-level model versioning
|
|
||||||
|
|
||||||
### Frontend Architecture
|
### Frontend Architecture
|
||||||
1. HTMX Integration
|
1. HTMX Integration
|
||||||
@@ -42,14 +32,12 @@
|
|||||||
- Partial page renders
|
- Partial page renders
|
||||||
- Server-side processing
|
- Server-side processing
|
||||||
- Progressive enhancement
|
- Progressive enhancement
|
||||||
- Version control UI updates
|
|
||||||
|
|
||||||
2. AlpineJS Usage
|
2. AlpineJS Usage
|
||||||
- UI state management
|
- UI state management
|
||||||
- Component behaviors
|
- Component behaviors
|
||||||
- Event handling
|
- Event handling
|
||||||
- DOM manipulation
|
- DOM manipulation
|
||||||
- Version control interactions
|
|
||||||
|
|
||||||
3. Tailwind CSS
|
3. Tailwind CSS
|
||||||
- Utility-first styling
|
- Utility-first styling
|
||||||
@@ -59,67 +47,32 @@
|
|||||||
|
|
||||||
## Integration Patterns
|
## Integration Patterns
|
||||||
|
|
||||||
### Version Control Integration
|
|
||||||
1. Model Integration
|
|
||||||
```python
|
|
||||||
class VersionedModel(HistoricalModel):
|
|
||||||
# Base class for version-controlled models
|
|
||||||
history = HistoricalRecords()
|
|
||||||
version_control = VersionControlManager()
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Change Tracking
|
|
||||||
```python
|
|
||||||
# Automatic change tracking
|
|
||||||
with branch_context(branch):
|
|
||||||
model.save() # Changes tracked in branch
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Batch Operations
|
|
||||||
```python
|
|
||||||
# Efficient batch processing
|
|
||||||
with BatchOperation() as batch:
|
|
||||||
batch.process_changes(changes)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Template System
|
### Template System
|
||||||
1. Structure
|
1. Structure
|
||||||
- Base templates
|
- Base templates
|
||||||
- Model-specific partials
|
- Model-specific partials
|
||||||
- Reusable components
|
- Reusable components
|
||||||
- Template inheritance
|
- Template inheritance
|
||||||
- Version control components
|
|
||||||
|
|
||||||
2. HTMX Patterns
|
2. HTMX Patterns
|
||||||
- Partial updates
|
- Partial updates
|
||||||
- Server triggers
|
- Server triggers
|
||||||
- Event handling
|
- Event handling
|
||||||
- Response processing
|
- Response processing
|
||||||
- Version history display
|
|
||||||
|
|
||||||
### State Management
|
### State Management
|
||||||
1. Server-side
|
1. Server-side
|
||||||
- Django sessions
|
- Django sessions
|
||||||
- Database state
|
- Database state
|
||||||
- Cache management
|
- Cache management
|
||||||
- Version control state
|
|
||||||
- Branch management
|
|
||||||
|
|
||||||
2. Client-side
|
2. Client-side
|
||||||
- AlpineJS state
|
- AlpineJS state
|
||||||
- Local storage
|
- Local storage
|
||||||
- HTMX state management
|
- HTMX state management
|
||||||
- Version control UI state
|
|
||||||
|
|
||||||
## Performance Requirements
|
## Performance Requirements
|
||||||
|
|
||||||
### Version Control Performance
|
|
||||||
- Batch processing for large changes
|
|
||||||
- Efficient caching with Redis
|
|
||||||
- Optimized query patterns
|
|
||||||
- Parallel processing capability
|
|
||||||
- Monitoring and metrics
|
|
||||||
|
|
||||||
### Frontend Targets
|
### Frontend Targets
|
||||||
- First contentful paint < 1.5s
|
- First contentful paint < 1.5s
|
||||||
- Time to interactive < 2s
|
- Time to interactive < 2s
|
||||||
@@ -132,25 +85,20 @@
|
|||||||
- Caching strategy
|
- Caching strategy
|
||||||
- Asset optimization
|
- Asset optimization
|
||||||
- API response times
|
- API response times
|
||||||
- Version control overhead management
|
|
||||||
|
|
||||||
## Development Environment
|
## Development Environment
|
||||||
|
|
||||||
### Required Tools
|
### Required Tools
|
||||||
- Python 3.8+ with virtual environment
|
- Python with virtual environment
|
||||||
- Node.js (Tailwind build)
|
- Node.js (Tailwind build)
|
||||||
- Git version control
|
- Git version control
|
||||||
- VSCode IDE
|
- VSCode IDE
|
||||||
- Redis 6.0+
|
|
||||||
- PostgreSQL 12+
|
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
- Environment variables
|
- Environment variables
|
||||||
- Development settings
|
- Development settings
|
||||||
- Database setup
|
- Database setup
|
||||||
- Media handling
|
- Media handling
|
||||||
- Redis configuration
|
|
||||||
- Version control settings
|
|
||||||
|
|
||||||
## Security Framework
|
## Security Framework
|
||||||
|
|
||||||
@@ -159,14 +107,12 @@
|
|||||||
- Session management
|
- Session management
|
||||||
- Permission levels
|
- Permission levels
|
||||||
- User roles
|
- User roles
|
||||||
- Version control access control
|
|
||||||
|
|
||||||
### Data Protection
|
### Data Protection
|
||||||
- CSRF protection
|
- CSRF protection
|
||||||
- XSS prevention
|
- XSS prevention
|
||||||
- SQL injection prevention
|
- SQL injection prevention
|
||||||
- Input validation
|
- Input validation
|
||||||
- Version history integrity
|
|
||||||
|
|
||||||
## Testing Strategy
|
## Testing Strategy
|
||||||
|
|
||||||
@@ -175,15 +121,12 @@
|
|||||||
- Unit tests
|
- Unit tests
|
||||||
- Integration tests
|
- Integration tests
|
||||||
- Coverage requirements
|
- Coverage requirements
|
||||||
- Version control tests
|
|
||||||
- Performance tests
|
|
||||||
|
|
||||||
### Frontend Testing
|
### Frontend Testing
|
||||||
- Browser testing
|
- Browser testing
|
||||||
- Performance metrics
|
- Performance metrics
|
||||||
- Accessibility testing
|
- Accessibility testing
|
||||||
- User flow validation
|
- User flow validation
|
||||||
- Version control UI testing
|
|
||||||
|
|
||||||
## Deployment Process
|
## Deployment Process
|
||||||
|
|
||||||
@@ -192,15 +135,12 @@
|
|||||||
- Database migration
|
- Database migration
|
||||||
- Static file handling
|
- Static file handling
|
||||||
- SSL/TLS setup
|
- SSL/TLS setup
|
||||||
- Redis setup
|
|
||||||
- Version control initialization
|
|
||||||
|
|
||||||
### Monitoring
|
### Monitoring
|
||||||
- Error tracking
|
- Error tracking
|
||||||
- Performance monitoring
|
- Performance monitoring
|
||||||
- User analytics
|
- User analytics
|
||||||
- System health checks
|
- System health checks
|
||||||
- Version control metrics
|
|
||||||
|
|
||||||
## Documentation Requirements
|
## Documentation Requirements
|
||||||
|
|
||||||
@@ -209,11 +149,9 @@
|
|||||||
- Type hints
|
- Type hints
|
||||||
- Component documentation
|
- Component documentation
|
||||||
- API documentation
|
- API documentation
|
||||||
- Version control documentation
|
|
||||||
|
|
||||||
### System Documentation
|
### System Documentation
|
||||||
- Setup guides
|
- Setup guides
|
||||||
- Architecture docs
|
- Architecture docs
|
||||||
- Maintenance procedures
|
- Maintenance procedures
|
||||||
- Troubleshooting guides
|
- Troubleshooting guides
|
||||||
- Version control guides
|
|
||||||
129
parks/models.py
129
parks/models.py
@@ -1,26 +1,21 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
from decimal import Decimal, ROUND_DOWN, InvalidOperation
|
||||||
from typing import Tuple, Optional, Any, TYPE_CHECKING
|
from typing import Tuple, Optional, Any, TYPE_CHECKING
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
|
||||||
|
|
||||||
from companies.models import Company
|
from companies.models import Company
|
||||||
from history_tracking.signals import get_current_branch
|
|
||||||
from media.models import Photo
|
from media.models import Photo
|
||||||
from history_tracking.models import HistoricalModel
|
from history_tracking.models import HistoricalModel
|
||||||
from location.models import Location
|
from location.models import Location
|
||||||
from comments.mixins import CommentableMixin
|
|
||||||
from media.mixins import PhotoableModel
|
|
||||||
from location.mixins import LocationMixin
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from rides.models import Ride
|
from rides.models import Ride
|
||||||
|
|
||||||
|
|
||||||
class Park(HistoricalModel, CommentableMixin, PhotoableModel, LocationMixin):
|
class Park(HistoricalModel):
|
||||||
comments = GenericRelation('comments.CommentThread') # Centralized reference
|
|
||||||
id: int # Type hint for Django's automatic id field
|
id: int # Type hint for Django's automatic id field
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
("OPERATING", "Operating"),
|
("OPERATING", "Operating"),
|
||||||
@@ -38,6 +33,9 @@ class Park(HistoricalModel, CommentableMixin, PhotoableModel, LocationMixin):
|
|||||||
max_length=20, choices=STATUS_CHOICES, default="OPERATING"
|
max_length=20, choices=STATUS_CHOICES, default="OPERATING"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Location fields using GenericRelation
|
||||||
|
location = GenericRelation(Location, related_query_name='park')
|
||||||
|
|
||||||
# Details
|
# Details
|
||||||
opening_date = models.DateField(null=True, blank=True)
|
opening_date = models.DateField(null=True, blank=True)
|
||||||
closing_date = models.DateField(null=True, blank=True)
|
closing_date = models.DateField(null=True, blank=True)
|
||||||
@@ -58,8 +56,8 @@ class Park(HistoricalModel, CommentableMixin, PhotoableModel, LocationMixin):
|
|||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="parks"
|
Company, on_delete=models.SET_NULL, null=True, blank=True, related_name="parks"
|
||||||
)
|
)
|
||||||
|
photos = GenericRelation(Photo, related_query_name="park")
|
||||||
areas: models.Manager['ParkArea'] # Type hint for reverse relation
|
areas: models.Manager['ParkArea'] # Type hint for reverse relation
|
||||||
|
|
||||||
rides: models.Manager['Ride'] # Type hint for reverse relation from rides app
|
rides: models.Manager['Ride'] # Type hint for reverse relation from rides app
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
@@ -68,7 +66,6 @@ class Park(HistoricalModel, CommentableMixin, PhotoableModel, LocationMixin):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
excluded_fields = ['comments'] # Exclude from historical tracking
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.name
|
return self.name
|
||||||
@@ -76,54 +73,28 @@ class Park(HistoricalModel, CommentableMixin, PhotoableModel, LocationMixin):
|
|||||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
# Get the branch from context or use default
|
|
||||||
from history_tracking.signals import get_current_branch
|
|
||||||
current_branch = get_current_branch()
|
|
||||||
|
|
||||||
if current_branch:
|
|
||||||
# Save in the context of the current branch
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
# If no branch context, save in main branch
|
|
||||||
from history_tracking.models import VersionBranch
|
|
||||||
main_branch, _ = VersionBranch.objects.get_or_create(
|
|
||||||
name='main',
|
|
||||||
defaults={'metadata': {'type': 'default_branch'}}
|
|
||||||
)
|
|
||||||
|
|
||||||
from history_tracking.signals import ChangesetContextManager
|
|
||||||
with ChangesetContextManager(branch=main_branch):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_version_info(self) -> dict:
|
|
||||||
"""Get version control information for this park"""
|
|
||||||
from history_tracking.models import VersionBranch, ChangeSet
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
|
|
||||||
content_type = ContentType.objects.get_for_model(self)
|
|
||||||
latest_changes = ChangeSet.objects.filter(
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=self.pk,
|
|
||||||
status='applied'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
active_branches = VersionBranch.objects.filter(
|
|
||||||
changesets__content_type=content_type,
|
|
||||||
changesets__object_id=self.pk,
|
|
||||||
is_active=True
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'latest_changes': latest_changes,
|
|
||||||
'active_branches': active_branches,
|
|
||||||
'current_branch': get_current_branch(),
|
|
||||||
'total_changes': latest_changes.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
def get_absolute_url(self) -> str:
|
||||||
return reverse("parks:park_detail", kwargs={"slug": self.slug})
|
return reverse("parks:park_detail", kwargs={"slug": self.slug})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def formatted_location(self) -> str:
|
||||||
|
if self.location.exists():
|
||||||
|
location = self.location.first()
|
||||||
|
if location:
|
||||||
|
return location.get_formatted_address()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def coordinates(self) -> Optional[Tuple[float, float]]:
|
||||||
|
"""Returns coordinates as a tuple (latitude, longitude)"""
|
||||||
|
if self.location.exists():
|
||||||
|
location = self.location.first()
|
||||||
|
if location:
|
||||||
|
return location.coordinates
|
||||||
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_slug(cls, slug: str) -> Tuple['Park', bool]:
|
def get_by_slug(cls, slug: str) -> Tuple['Park', bool]:
|
||||||
"""Get park by current or historical slug"""
|
"""Get park by current or historical slug"""
|
||||||
@@ -140,8 +111,7 @@ class Park(HistoricalModel, CommentableMixin, PhotoableModel, LocationMixin):
|
|||||||
raise cls.DoesNotExist("No park found with this slug")
|
raise cls.DoesNotExist("No park found with this slug")
|
||||||
|
|
||||||
|
|
||||||
class ParkArea(HistoricalModel, CommentableMixin, PhotoableModel):
|
class ParkArea(HistoricalModel):
|
||||||
comments = GenericRelation('comments.CommentThread') # Centralized reference
|
|
||||||
id: int # Type hint for Django's automatic id field
|
id: int # Type hint for Django's automatic id field
|
||||||
park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name="areas")
|
park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name="areas")
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
@@ -150,8 +120,6 @@ class ParkArea(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
opening_date = models.DateField(null=True, blank=True)
|
opening_date = models.DateField(null=True, blank=True)
|
||||||
closing_date = models.DateField(null=True, blank=True)
|
closing_date = models.DateField(null=True, blank=True)
|
||||||
|
|
||||||
# Relationships
|
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
created_at = models.DateTimeField(auto_now_add=True, null=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
@@ -159,7 +127,6 @@ class ParkArea(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
unique_together = ["park", "slug"]
|
unique_together = ["park", "slug"]
|
||||||
excluded_fields = ['comments'] # Exclude from historical tracking
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.name} at {self.park.name}"
|
return f"{self.name} at {self.park.name}"
|
||||||
@@ -167,51 +134,7 @@ class ParkArea(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
def save(self, *args: Any, **kwargs: Any) -> None:
|
def save(self, *args: Any, **kwargs: Any) -> None:
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
# Get the branch from context or use default
|
|
||||||
from history_tracking.signals import get_current_branch
|
|
||||||
current_branch = get_current_branch()
|
|
||||||
|
|
||||||
if current_branch:
|
|
||||||
# Save in the context of the current branch
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
# If no branch context, save in main branch
|
|
||||||
from history_tracking.models import VersionBranch
|
|
||||||
main_branch, _ = VersionBranch.objects.get_or_create(
|
|
||||||
name='main',
|
|
||||||
defaults={'metadata': {'type': 'default_branch'}}
|
|
||||||
)
|
|
||||||
|
|
||||||
from history_tracking.signals import ChangesetContextManager
|
|
||||||
with ChangesetContextManager(branch=main_branch):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_version_info(self) -> dict:
|
|
||||||
"""Get version control information for this park area"""
|
|
||||||
from history_tracking.models import VersionBranch, ChangeSet
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
|
|
||||||
content_type = ContentType.objects.get_for_model(self)
|
|
||||||
latest_changes = ChangeSet.objects.filter(
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=self.pk,
|
|
||||||
status='applied'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
active_branches = VersionBranch.objects.filter(
|
|
||||||
changesets__content_type=content_type,
|
|
||||||
changesets__object_id=self.pk,
|
|
||||||
is_active=True
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'latest_changes': latest_changes,
|
|
||||||
'active_branches': active_branches,
|
|
||||||
'current_branch': get_current_branch(),
|
|
||||||
'total_changes': latest_changes.count(),
|
|
||||||
'parent_park_branch': self.park.get_version_info()['current_branch']
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
def get_absolute_url(self) -> str:
|
||||||
return reverse(
|
return reverse(
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}{{ park.name }} - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
<!-- Main Content Column -->
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<!-- Park Information -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<div class="flex justify-between items-start">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">{{ park.name }}</h1>
|
|
||||||
<span class="px-3 py-1 rounded text-sm
|
|
||||||
{% if park.status == 'OPERATING' %}
|
|
||||||
bg-green-100 text-green-800
|
|
||||||
{% elif park.status == 'CLOSED_TEMP' %}
|
|
||||||
bg-yellow-100 text-yellow-800
|
|
||||||
{% elif park.status == 'UNDER_CONSTRUCTION' %}
|
|
||||||
bg-blue-100 text-blue-800
|
|
||||||
{% else %}
|
|
||||||
bg-red-100 text-red-800
|
|
||||||
{% endif %}">
|
|
||||||
{{ park.get_status_display }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if park.description %}
|
|
||||||
<div class="mt-4 prose">
|
|
||||||
{{ park.description|linebreaks }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Park Details -->
|
|
||||||
<div class="mt-6 grid grid-cols-2 gap-4">
|
|
||||||
{% if park.opening_date %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Opening Date</h3>
|
|
||||||
<p class="mt-1">{{ park.opening_date }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if park.size_acres %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Size</h3>
|
|
||||||
<p class="mt-1">{{ park.size_acres }} acres</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if park.operating_season %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Operating Season</h3>
|
|
||||||
<p class="mt-1">{{ park.operating_season }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if park.owner %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Owner</h3>
|
|
||||||
<p class="mt-1">
|
|
||||||
<a href="{{ park.owner.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ park.owner.name }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rides Section -->
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Rides</h2>
|
|
||||||
{% if park.rides.all %}
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{% for ride in park.rides.all %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
||||||
<h3 class="text-lg font-semibold">
|
|
||||||
<a href="{{ ride.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ ride.name }}
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">{{ ride.type }}</p>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="text-gray-600">No rides listed yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Areas Section -->
|
|
||||||
{% if park.areas.exists %}
|
|
||||||
<div class="mt-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Areas</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{% for area in park.areas.all %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4">
|
|
||||||
<h3 class="text-lg font-semibold">
|
|
||||||
<a href="{{ area.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ area.name }}
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
{% if area.description %}
|
|
||||||
<p class="text-sm text-gray-600 mt-1">{{ area.description|truncatewords:20 }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<!-- Location -->
|
|
||||||
{% if park.formatted_location %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Location</h2>
|
|
||||||
<p>{{ park.formatted_location }}</p>
|
|
||||||
{% if park.coordinates %}
|
|
||||||
<div id="map"
|
|
||||||
class="h-64 mt-4 rounded-lg"
|
|
||||||
data-lat="{{ park.coordinates.0|stringformat:'f' }}"
|
|
||||||
data-lng="{{ park.coordinates.1|stringformat:'f' }}"
|
|
||||||
data-name="{{ park.name|escapejs }}">
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Statistics -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Statistics</h2>
|
|
||||||
<div class="space-y-3">
|
|
||||||
{% if park.average_rating %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Average Rating:</span>
|
|
||||||
<span class="font-medium">{{ park.average_rating }}/5</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if park.ride_count %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Total Rides:</span>
|
|
||||||
<span class="font-medium">{{ park.ride_count }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if park.coaster_count %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Roller Coasters:</span>
|
|
||||||
<span class="font-medium">{{ park.coaster_count }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Photo Gallery -->
|
|
||||||
{% if park.photos.exists %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3" id="photo-gallery">Photo Gallery</h2>
|
|
||||||
<ul class="grid grid-cols-2 gap-2 list-none p-0"
|
|
||||||
aria-labelledby="photo-gallery">
|
|
||||||
{% for photo in park.photos.all|slice:":4" %}
|
|
||||||
<li class="aspect-w-1 aspect-h-1">
|
|
||||||
<img src="{{ photo.image.url }}"
|
|
||||||
alt="{% if photo.title %}{{ photo.title }} at {% endif %}{{ park.name }}"
|
|
||||||
class="object-cover rounded"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
fetchpriority="low"
|
|
||||||
width="300"
|
|
||||||
height="300">
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% if park.photos.count > 4 %}
|
|
||||||
<a href="{% url 'photos:park-gallery' park.slug %}"
|
|
||||||
class="text-blue-600 hover:underline text-sm block mt-3"
|
|
||||||
aria-label="View full photo gallery of {{ park.name }}">
|
|
||||||
View all {{ park.photos.count }} photos
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_js %}
|
|
||||||
{{ block.super }}
|
|
||||||
{% if park.coordinates %}
|
|
||||||
<script src="{% static 'js/map-init.js' %}"></script>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
|
||||||
@@ -55,7 +55,5 @@ dependencies = [
|
|||||||
"django-simple-history>=3.5.0",
|
"django-simple-history>=3.5.0",
|
||||||
"django-tailwind-cli>=2.21.1",
|
"django-tailwind-cli>=2.21.1",
|
||||||
"playwright>=1.41.0",
|
"playwright>=1.41.0",
|
||||||
"pytest-playwright>=0.4.3",
|
"pytest-playwright>=0.4.3"
|
||||||
"celery>=5.4.0",
|
|
||||||
"django-redis>=5.4.0",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ pyjwt==2.10.1
|
|||||||
# Database
|
# Database
|
||||||
psycopg2-binary==2.9.10
|
psycopg2-binary==2.9.10
|
||||||
dj-database-url==2.3.0
|
dj-database-url==2.3.0
|
||||||
django-redis==5.4.0
|
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
requests==2.32.3 # For ForwardEmail.net API
|
requests==2.32.3 # For ForwardEmail.net API
|
||||||
@@ -45,4 +44,3 @@ daphne==4.1.2
|
|||||||
# React and Material UI will be handled via npm in the frontend directory
|
# React and Material UI will be handled via npm in the frontend directory
|
||||||
django-simple-history==3.8.0
|
django-simple-history==3.8.0
|
||||||
django-tailwind-cli==2.21.1
|
django-tailwind-cli==2.21.1
|
||||||
celery==5.3.6
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from media.admin import PhotoInline
|
from .models import Review, ReviewImage, ReviewLike, ReviewReport
|
||||||
from .models import Review, ReviewLike, ReviewReport
|
|
||||||
|
class ReviewImageInline(admin.TabularInline):
|
||||||
|
model = ReviewImage
|
||||||
|
extra = 1
|
||||||
|
fields = ('image', 'caption', 'order')
|
||||||
|
|
||||||
@admin.register(Review)
|
@admin.register(Review)
|
||||||
class ReviewAdmin(admin.ModelAdmin):
|
class ReviewAdmin(admin.ModelAdmin):
|
||||||
@@ -10,7 +14,7 @@ class ReviewAdmin(admin.ModelAdmin):
|
|||||||
search_fields = ('user__username', 'content', 'title')
|
search_fields = ('user__username', 'content', 'title')
|
||||||
readonly_fields = ('created_at', 'updated_at')
|
readonly_fields = ('created_at', 'updated_at')
|
||||||
actions = ['publish_reviews', 'unpublish_reviews']
|
actions = ['publish_reviews', 'unpublish_reviews']
|
||||||
inlines = [PhotoInline]
|
inlines = [ReviewImageInline]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Review Details', {
|
('Review Details', {
|
||||||
@@ -51,6 +55,13 @@ class ReviewAdmin(admin.ModelAdmin):
|
|||||||
queryset.update(is_published=False)
|
queryset.update(is_published=False)
|
||||||
unpublish_reviews.short_description = "Unpublish selected reviews"
|
unpublish_reviews.short_description = "Unpublish selected reviews"
|
||||||
|
|
||||||
|
@admin.register(ReviewImage)
|
||||||
|
class ReviewImageAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('review', 'caption', 'order')
|
||||||
|
list_filter = ('review__created_at',)
|
||||||
|
search_fields = ('review__title', 'caption')
|
||||||
|
ordering = ('review', 'order')
|
||||||
|
|
||||||
@admin.register(ReviewLike)
|
@admin.register(ReviewLike)
|
||||||
class ReviewLikeAdmin(admin.ModelAdmin):
|
class ReviewLikeAdmin(admin.ModelAdmin):
|
||||||
list_display = ('review', 'user', 'created_at')
|
list_display = ('review', 'user', 'created_at')
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.db.models import QuerySet
|
|
||||||
|
|
||||||
class ReviewableMixin:
|
|
||||||
"""Mixin for models that can have reviews."""
|
|
||||||
|
|
||||||
def get_reviews(self) -> QuerySet:
|
|
||||||
"""Get reviews for this instance."""
|
|
||||||
from reviews.models import Review
|
|
||||||
ct = ContentType.objects.get_for_model(self.__class__)
|
|
||||||
return Review.objects.filter(content_type=ct, object_id=self.pk)
|
|
||||||
|
|
||||||
def add_review(self, review: 'Review') -> None:
|
|
||||||
"""Add a review to this instance."""
|
|
||||||
from reviews.models import Review
|
|
||||||
ct = ContentType.objects.get_for_model(self.__class__)
|
|
||||||
review.content_type = ct
|
|
||||||
review.object_id = self.pk
|
|
||||||
review.save()
|
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
from history_tracking.models import HistoricalModel, VersionBranch, ChangeSet
|
|
||||||
from history_tracking.signals import get_current_branch, ChangesetContextManager
|
|
||||||
from comments.mixins import CommentableMixin
|
|
||||||
from media.mixins import PhotoableModel
|
|
||||||
|
|
||||||
class Review(HistoricalModel, CommentableMixin, PhotoableModel):
|
class Review(models.Model):
|
||||||
comments = GenericRelation('comments.CommentThread') # Centralized reference
|
|
||||||
# Generic relation to allow reviews on different types (rides, parks)
|
# Generic relation to allow reviews on different types (rides, parks)
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
object_id = models.PositiveIntegerField()
|
object_id = models.PositiveIntegerField()
|
||||||
@@ -44,68 +38,30 @@ class Review(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
)
|
)
|
||||||
moderated_at = models.DateTimeField(null=True, blank=True)
|
moderated_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-created_at']
|
ordering = ['-created_at']
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['content_type', 'object_id']),
|
models.Index(fields=['content_type', 'object_id']),
|
||||||
]
|
]
|
||||||
excluded_fields = ['comments'] # Exclude from historical tracking
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Review of {self.content_object} by {self.user.username}"
|
return f"Review of {self.content_object} by {self.user.username}"
|
||||||
|
|
||||||
def save(self, *args, **kwargs) -> None:
|
class ReviewImage(models.Model):
|
||||||
# Get the branch from context or use default
|
review = models.ForeignKey(
|
||||||
current_branch = get_current_branch()
|
Review,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='images'
|
||||||
|
)
|
||||||
|
image = models.ImageField(upload_to='review_images/')
|
||||||
|
caption = models.CharField(max_length=200, blank=True)
|
||||||
|
order = models.PositiveIntegerField(default=0)
|
||||||
|
|
||||||
if current_branch:
|
class Meta:
|
||||||
# Save in the context of the current branch
|
ordering = ['order']
|
||||||
super().save(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
# If no branch context, save in main branch
|
|
||||||
main_branch, _ = VersionBranch.objects.get_or_create(
|
|
||||||
name='main',
|
|
||||||
defaults={'metadata': {'type': 'default_branch'}}
|
|
||||||
)
|
|
||||||
|
|
||||||
with ChangesetContextManager(branch=main_branch):
|
def __str__(self):
|
||||||
super().save(*args, **kwargs)
|
return f"Image {self.order + 1} for {self.review}"
|
||||||
|
|
||||||
def get_version_info(self) -> dict:
|
|
||||||
"""Get version control information for this review and its reviewed object"""
|
|
||||||
content_type = ContentType.objects.get_for_model(self)
|
|
||||||
latest_changes = ChangeSet.objects.filter(
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=self.pk,
|
|
||||||
status='applied'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
active_branches = VersionBranch.objects.filter(
|
|
||||||
changesets__content_type=content_type,
|
|
||||||
changesets__object_id=self.pk,
|
|
||||||
is_active=True
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
# Get version info for the reviewed object if it's version controlled
|
|
||||||
reviewed_object_branch = None
|
|
||||||
if hasattr(self.content_object, 'get_version_info'):
|
|
||||||
reviewed_object_branch = self.content_object.get_version_info().get('current_branch')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'latest_changes': latest_changes,
|
|
||||||
'active_branches': active_branches,
|
|
||||||
'current_branch': get_current_branch(),
|
|
||||||
'total_changes': latest_changes.count(),
|
|
||||||
'reviewed_object_branch': reviewed_object_branch
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
|
||||||
"""Get the absolute URL for this review"""
|
|
||||||
if hasattr(self.content_object, 'get_absolute_url'):
|
|
||||||
base_url = self.content_object.get_absolute_url()
|
|
||||||
return f"{base_url}#review-{self.pk}"
|
|
||||||
return reverse('reviews:review_detail', kwargs={'pk': self.pk})
|
|
||||||
|
|
||||||
class ReviewLike(models.Model):
|
class ReviewLike(models.Model):
|
||||||
review = models.ForeignKey(
|
review = models.ForeignKey(
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Review of {{ review.content_object.name }} by {{ review.user.username }} - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
<!-- Main Content Column -->
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<!-- Review Information -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<div class="flex justify-between items-start mb-4">
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Review of {{ review.content_object.name }}</h1>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<span class="px-3 py-1 rounded text-sm
|
|
||||||
{% if review.is_published %}
|
|
||||||
bg-green-100 text-green-800
|
|
||||||
{% else %}
|
|
||||||
bg-red-100 text-red-800
|
|
||||||
{% endif %}">
|
|
||||||
{{ review.is_published|yesno:"Published,Unpublished" }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-4">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<div class="text-2xl font-bold text-blue-600">{{ review.rating }}/10</div>
|
|
||||||
<div class="text-sm text-gray-500">Visited on {{ review.visit_date|date:"F j, Y" }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold mb-2">{{ review.title }}</h2>
|
|
||||||
|
|
||||||
<div class="prose max-w-none">
|
|
||||||
{{ review.content|linebreaks }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Review Images -->
|
|
||||||
{% if review.images.exists %}
|
|
||||||
<div class="mt-6">
|
|
||||||
<h3 class="text-lg font-semibold mb-3">Photos</h3>
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
{% for image in review.images.all %}
|
|
||||||
<div class="aspect-w-16 aspect-h-9">
|
|
||||||
<img src="{{ image.image.url }}"
|
|
||||||
alt="{{ image.caption|default:'Review photo' }}"
|
|
||||||
class="object-cover rounded-lg"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async">
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Moderation Information -->
|
|
||||||
{% if review.moderated_by %}
|
|
||||||
<div class="mt-6 bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h3 class="text-lg font-semibold mb-3">Moderation Details</h3>
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<p><span class="text-gray-600">Moderated by:</span> {{ review.moderated_by.username }}</p>
|
|
||||||
<p><span class="text-gray-600">Moderated on:</span> {{ review.moderated_at|date:"F j, Y H:i" }}</p>
|
|
||||||
{% if review.moderation_notes %}
|
|
||||||
<div class="mt-2">
|
|
||||||
<span class="text-gray-600">Notes:</span>
|
|
||||||
<p class="mt-1 text-gray-700">{{ review.moderation_notes|linebreaks }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<!-- Reviewed Item -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">{{ review.content_object|class_name }}</h2>
|
|
||||||
<div>
|
|
||||||
<a href="{{ review.content_object.get_absolute_url }}"
|
|
||||||
class="text-blue-600 hover:underline text-lg">
|
|
||||||
{{ review.content_object.name }}
|
|
||||||
</a>
|
|
||||||
{% if review.content_object.park %}
|
|
||||||
<p class="text-gray-600 mt-1">
|
|
||||||
at
|
|
||||||
<a href="{{ review.content_object.park.get_absolute_url }}"
|
|
||||||
class="hover:underline">
|
|
||||||
{{ review.content_object.park.name }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reviewer Information -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Reviewer</h2>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
{% if review.user.avatar %}
|
|
||||||
<img src="{{ review.user.avatar.url }}"
|
|
||||||
alt="{{ review.user.username }}"
|
|
||||||
class="w-12 h-12 rounded-full">
|
|
||||||
{% endif %}
|
|
||||||
<div>
|
|
||||||
<div class="font-medium">{{ review.user.username }}</div>
|
|
||||||
<div class="text-sm text-gray-500">Member since {{ review.user.date_joined|date:"F Y" }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 text-sm">
|
|
||||||
<p><span class="text-gray-600">Reviews:</span> {{ review.user.reviews.count }}</p>
|
|
||||||
<p><span class="text-gray-600">Helpful votes:</span> {{ review.user.review_likes.count }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Review Metadata -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Review Details</h2>
|
|
||||||
<div class="space-y-2 text-sm">
|
|
||||||
<p><span class="text-gray-600">Created:</span> {{ review.created_at|date:"F j, Y H:i" }}</p>
|
|
||||||
{% if review.created_at != review.updated_at %}
|
|
||||||
<p><span class="text-gray-600">Last updated:</span> {{ review.updated_at|date:"F j, Y H:i" }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<p><span class="text-gray-600">Helpful votes:</span> {{ review.likes.count }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Reviews - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Reviews</h1>
|
|
||||||
{% if object %}
|
|
||||||
<p class="text-gray-600 mt-2">Reviews for {{ object.name }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
||||||
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<label for="rating" class="block text-sm font-medium text-gray-700">Rating</label>
|
|
||||||
<select name="rating" id="rating" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="">All Ratings</option>
|
|
||||||
{% for i in "12345678910"|make_list %}
|
|
||||||
<option value="{{ i }}" {% if rating == i %}selected{% endif %}>{{ i }}/10 or higher</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="type" class="block text-sm font-medium text-gray-700">Type</label>
|
|
||||||
<select name="type" id="type" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="">All Types</option>
|
|
||||||
<option value="park" {% if type == 'park' %}selected{% endif %}>Parks</option>
|
|
||||||
<option value="ride" {% if type == 'ride' %}selected{% endif %}>Rides</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="order" class="block text-sm font-medium text-gray-700">Sort By</label>
|
|
||||||
<select name="order" id="order" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="-created_at" {% if order == '-created_at' %}selected{% endif %}>Newest First</option>
|
|
||||||
<option value="created_at" {% if order == 'created_at' %}selected{% endif %}>Oldest First</option>
|
|
||||||
<option value="-rating" {% if order == '-rating' %}selected{% endif %}>Highest Rated</option>
|
|
||||||
<option value="rating" {% if order == 'rating' %}selected{% endif %}>Lowest Rated</option>
|
|
||||||
<option value="-likes" {% if order == '-likes' %}selected{% endif %}>Most Helpful</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end">
|
|
||||||
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded">
|
|
||||||
Apply Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Reviews Grid -->
|
|
||||||
{% if reviews %}
|
|
||||||
<div class="grid grid-cols-1 gap-6">
|
|
||||||
{% for review in reviews %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
||||||
<div class="p-6">
|
|
||||||
<div class="flex justify-between items-start mb-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-xl font-semibold mb-1">
|
|
||||||
<a href="{{ review.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ review.title }}
|
|
||||||
</a>
|
|
||||||
</h2>
|
|
||||||
<p class="text-gray-600">
|
|
||||||
Review of
|
|
||||||
<a href="{{ review.content_object.get_absolute_url }}" class="hover:underline">
|
|
||||||
{{ review.content_object.name }}
|
|
||||||
</a>
|
|
||||||
{% if review.content_object.park %}
|
|
||||||
at
|
|
||||||
<a href="{{ review.content_object.park.get_absolute_url }}" class="hover:underline">
|
|
||||||
{{ review.content_object.park.name }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="text-2xl font-bold text-blue-600 mr-3">{{ review.rating }}/10</div>
|
|
||||||
<span class="px-3 py-1 rounded text-sm
|
|
||||||
{% if review.is_published %}
|
|
||||||
bg-green-100 text-green-800
|
|
||||||
{% else %}
|
|
||||||
bg-red-100 text-red-800
|
|
||||||
{% endif %}">
|
|
||||||
{{ review.is_published|yesno:"Published,Unpublished" }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="prose max-w-none mb-4">
|
|
||||||
{{ review.content|truncatewords:50 }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Version Control Status -->
|
|
||||||
{% with version_info=review.get_version_info %}
|
|
||||||
{% if version_info.active_branches.count > 1 %}
|
|
||||||
<div class="mt-3 text-sm">
|
|
||||||
<span class="text-yellow-600">
|
|
||||||
{{ version_info.active_branches.count }} active branches
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between text-sm text-gray-500">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<div>
|
|
||||||
by <span class="font-medium">{{ review.user.username }}</span>
|
|
||||||
</div>
|
|
||||||
<div>{{ review.visit_date|date:"F j, Y" }}</div>
|
|
||||||
<div>{{ review.likes.count }} helpful votes</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{{ review.created_at|date:"F j, Y" }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{% if is_paginated %}
|
|
||||||
<div class="mt-8 flex justify-center">
|
|
||||||
<nav class="inline-flex rounded-md shadow">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<a href="?page={{ page_obj.previous_page_number }}{% if request.GET.rating %}&rating={{ request.GET.rating }}{% endif %}{% if request.GET.type %}&type={{ request.GET.type }}{% endif %}{% if request.GET.order %}&order={{ request.GET.order }}{% endif %}"
|
|
||||||
class="px-3 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
||||||
Previous
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<a href="?page={{ page_obj.next_page_number }}{% if request.GET.rating %}&rating={{ request.GET.rating }}{% endif %}{% if request.GET.type %}&type={{ request.GET.type }}{% endif %}{% if request.GET.order %}&order={{ request.GET.order }}{% endif %}"
|
|
||||||
class="px-3 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
||||||
Next
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<p class="text-gray-600">No reviews found matching your criteria.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
126
rides/models.py
126
rides/models.py
@@ -1,13 +1,7 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from history_tracking.models import HistoricalModel, VersionBranch, ChangeSet
|
from history_tracking.models import HistoricalModel
|
||||||
from history_tracking.signals import get_current_branch, ChangesetContextManager
|
|
||||||
from comments.mixins import CommentableMixin
|
|
||||||
from media.mixins import PhotoableModel
|
|
||||||
from reviews.mixins import ReviewableMixin
|
|
||||||
|
|
||||||
|
|
||||||
# Shared choices that will be used by multiple models
|
# Shared choices that will be used by multiple models
|
||||||
@@ -22,8 +16,7 @@ CATEGORY_CHOICES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class RideModel(HistoricalModel, CommentableMixin, PhotoableModel):
|
class RideModel(HistoricalModel):
|
||||||
comments = GenericRelation('comments.CommentThread') # Centralized reference
|
|
||||||
"""
|
"""
|
||||||
Represents a specific model/type of ride that can be manufactured by different companies.
|
Represents a specific model/type of ride that can be manufactured by different companies.
|
||||||
For example: B&M Dive Coaster, Vekoma Boomerang, etc.
|
For example: B&M Dive Coaster, Vekoma Boomerang, etc.
|
||||||
@@ -46,60 +39,15 @@ class RideModel(HistoricalModel, CommentableMixin, PhotoableModel):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['manufacturer', 'name']
|
ordering = ['manufacturer', 'name']
|
||||||
unique_together = ['manufacturer', 'name']
|
unique_together = ['manufacturer', 'name']
|
||||||
excluded_fields = ['comments'] # Exclude from historical tracking
|
|
||||||
def __str__(self) -> str:
|
|
||||||
return self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}"
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs) -> None:
|
def __str__(self) -> str:
|
||||||
# Get the branch from context or use default
|
return self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}"
|
||||||
current_branch = get_current_branch()
|
|
||||||
|
|
||||||
if current_branch:
|
|
||||||
# Save in the context of the current branch
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
# If no branch context, save in main branch
|
|
||||||
main_branch, _ = VersionBranch.objects.get_or_create(
|
|
||||||
name='main',
|
|
||||||
defaults={'metadata': {'type': 'default_branch'}}
|
|
||||||
)
|
|
||||||
|
|
||||||
with ChangesetContextManager(branch=main_branch):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_version_info(self) -> dict:
|
|
||||||
"""Get version control information for this ride model"""
|
|
||||||
content_type = ContentType.objects.get_for_model(self)
|
|
||||||
latest_changes = ChangeSet.objects.filter(
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=self.pk,
|
|
||||||
status='applied'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
active_branches = VersionBranch.objects.filter(
|
|
||||||
changesets__content_type=content_type,
|
|
||||||
changesets__object_id=self.pk,
|
|
||||||
is_active=True
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'latest_changes': latest_changes,
|
|
||||||
'active_branches': active_branches,
|
|
||||||
'current_branch': get_current_branch(),
|
|
||||||
'total_changes': latest_changes.count()
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
|
||||||
return reverse("rides:model_detail", kwargs={"pk": self.pk})
|
|
||||||
|
|
||||||
|
|
||||||
|
class Ride(HistoricalModel):
|
||||||
class Ride(HistoricalModel, CommentableMixin, PhotoableModel, ReviewableMixin):
|
|
||||||
comments = GenericRelation('comments.CommentThread') # Centralized reference
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('OPERATING', 'Operating'),
|
('OPERATING', 'Operating'),
|
||||||
('SBNO', 'Standing But Not Operating'),
|
('SBNO', 'Standing But Not Operating'),
|
||||||
@@ -184,11 +132,12 @@ class Ride(HistoricalModel, CommentableMixin, PhotoableModel, ReviewableMixin):
|
|||||||
)
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
photos = GenericRelation('media.Photo')
|
||||||
|
reviews = GenericRelation('reviews.Review')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['name']
|
||||||
unique_together = ['park', 'slug']
|
unique_together = ['park', 'slug']
|
||||||
excluded_fields = ['comments'] # Exclude from historical tracking
|
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return f"{self.name} at {self.park.name}"
|
return f"{self.name} at {self.park.name}"
|
||||||
@@ -196,66 +145,7 @@ class Ride(HistoricalModel, CommentableMixin, PhotoableModel, ReviewableMixin):
|
|||||||
def save(self, *args, **kwargs) -> None:
|
def save(self, *args, **kwargs) -> None:
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
# Get the branch from context or use default
|
|
||||||
current_branch = get_current_branch()
|
|
||||||
|
|
||||||
if current_branch:
|
|
||||||
# Save in the context of the current branch
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
# If no branch context, save in main branch
|
|
||||||
main_branch, _ = VersionBranch.objects.get_or_create(
|
|
||||||
name='main',
|
|
||||||
defaults={'metadata': {'type': 'default_branch'}}
|
|
||||||
)
|
|
||||||
|
|
||||||
with ChangesetContextManager(branch=main_branch):
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
def get_version_info(self) -> dict:
|
|
||||||
"""Get version control information for this ride"""
|
|
||||||
content_type = ContentType.objects.get_for_model(self)
|
|
||||||
latest_changes = ChangeSet.objects.filter(
|
|
||||||
content_type=content_type,
|
|
||||||
object_id=self.pk,
|
|
||||||
status='applied'
|
|
||||||
).order_by('-created_at')[:5]
|
|
||||||
|
|
||||||
active_branches = VersionBranch.objects.filter(
|
|
||||||
changesets__content_type=content_type,
|
|
||||||
changesets__object_id=self.pk,
|
|
||||||
is_active=True
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
return {
|
|
||||||
'latest_changes': latest_changes,
|
|
||||||
'active_branches': active_branches,
|
|
||||||
'current_branch': get_current_branch(),
|
|
||||||
'total_changes': latest_changes.count(),
|
|
||||||
'parent_park_branch': self.park.get_version_info()['current_branch']
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_absolute_url(self) -> str:
|
|
||||||
return reverse("rides:ride_detail", kwargs={
|
|
||||||
"park_slug": self.park.slug,
|
|
||||||
"ride_slug": self.slug
|
|
||||||
})
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_by_slug(cls, slug: str) -> tuple['Ride', bool]:
|
|
||||||
"""Get ride by current or historical slug"""
|
|
||||||
try:
|
|
||||||
return cls.objects.get(slug=slug), False
|
|
||||||
except cls.DoesNotExist:
|
|
||||||
# Check historical slugs
|
|
||||||
history = cls.history.filter(slug=slug).order_by("-history_date").first()
|
|
||||||
if history:
|
|
||||||
try:
|
|
||||||
return cls.objects.get(pk=history.instance.pk), True
|
|
||||||
except cls.DoesNotExist as e:
|
|
||||||
raise cls.DoesNotExist("No ride found with this slug") from e
|
|
||||||
raise cls.DoesNotExist("No ride found with this slug")
|
|
||||||
|
|
||||||
|
|
||||||
class RollerCoasterStats(models.Model):
|
class RollerCoasterStats(models.Model):
|
||||||
|
|||||||
@@ -1,220 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}{{ ride.name }} at {{ ride.park.name }} - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
<!-- Main Content Column -->
|
|
||||||
<div class="lg:col-span-2">
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<!-- Ride Information -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<div class="flex justify-between items-start">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">{{ ride.name }}</h1>
|
|
||||||
<span class="px-3 py-1 rounded text-sm
|
|
||||||
{% if ride.status == 'OPERATING' %}
|
|
||||||
bg-green-100 text-green-800
|
|
||||||
{% elif ride.status == 'SBNO' %}
|
|
||||||
bg-yellow-100 text-yellow-800
|
|
||||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}
|
|
||||||
bg-blue-100 text-blue-800
|
|
||||||
{% else %}
|
|
||||||
bg-red-100 text-red-800
|
|
||||||
{% endif %}">
|
|
||||||
{{ ride.get_status_display }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if ride.description %}
|
|
||||||
<div class="mt-4 prose">
|
|
||||||
{{ ride.description|linebreaks }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Ride Details -->
|
|
||||||
<div class="mt-6 grid grid-cols-2 gap-4">
|
|
||||||
{% if ride.opening_date %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Opening Date</h3>
|
|
||||||
<p class="mt-1">{{ ride.opening_date }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.manufacturer %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Manufacturer</h3>
|
|
||||||
<p class="mt-1">
|
|
||||||
<a href="{{ ride.manufacturer.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ ride.manufacturer.name }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.designer %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Designer</h3>
|
|
||||||
<p class="mt-1">
|
|
||||||
<a href="{{ ride.designer.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ ride.designer.name }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.ride_model %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Ride Model</h3>
|
|
||||||
<p class="mt-1">{{ ride.ride_model.name }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.park_area %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Location</h3>
|
|
||||||
<p class="mt-1">
|
|
||||||
<a href="{{ ride.park_area.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ ride.park_area.name }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.capacity_per_hour %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Hourly Capacity</h3>
|
|
||||||
<p class="mt-1">{{ ride.capacity_per_hour }} riders/hour</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.ride_duration_seconds %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Ride Duration</h3>
|
|
||||||
<p class="mt-1">{{ ride.ride_duration_seconds }} seconds</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Roller Coaster Stats -->
|
|
||||||
{% if ride.coaster_stats %}
|
|
||||||
<div class="mt-8 bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">Coaster Statistics</h2>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
{% if ride.coaster_stats.height_ft %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Height</h3>
|
|
||||||
<p class="mt-1">{{ ride.coaster_stats.height_ft }} ft</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.coaster_stats.length_ft %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Length</h3>
|
|
||||||
<p class="mt-1">{{ ride.coaster_stats.length_ft }} ft</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.coaster_stats.speed_mph %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Speed</h3>
|
|
||||||
<p class="mt-1">{{ ride.coaster_stats.speed_mph }} mph</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.coaster_stats.inversions %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Inversions</h3>
|
|
||||||
<p class="mt-1">{{ ride.coaster_stats.inversions }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.coaster_stats.track_material %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Track Material</h3>
|
|
||||||
<p class="mt-1">{{ ride.coaster_stats.get_track_material_display }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.coaster_stats.roller_coaster_type %}
|
|
||||||
<div>
|
|
||||||
<h3 class="text-sm font-medium text-gray-500">Type</h3>
|
|
||||||
<p class="mt-1">{{ ride.coaster_stats.get_roller_coaster_type_display }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="lg:col-span-1">
|
|
||||||
<!-- Park Location -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Location</h2>
|
|
||||||
<p>
|
|
||||||
<a href="{{ ride.park.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ ride.park.name }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{% if ride.park.formatted_location %}
|
|
||||||
<p class="text-gray-600 mt-2">{{ ride.park.formatted_location }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Statistics -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3">Statistics</h2>
|
|
||||||
<div class="space-y-3">
|
|
||||||
{% if ride.average_rating %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Average Rating:</span>
|
|
||||||
<span class="font-medium">{{ ride.average_rating }}/5</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.reviews.count %}
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-600">Reviews:</span>
|
|
||||||
<span class="font-medium">{{ ride.reviews.count }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Photo Gallery -->
|
|
||||||
{% if ride.photos.exists %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
|
||||||
<h2 class="text-lg font-semibold mb-3" id="photo-gallery">Photo Gallery</h2>
|
|
||||||
<ul class="grid grid-cols-2 gap-2 list-none p-0"
|
|
||||||
aria-labelledby="photo-gallery">
|
|
||||||
{% for photo in ride.photos.all|slice:":4" %}
|
|
||||||
<li class="aspect-w-1 aspect-h-1">
|
|
||||||
<img src="{{ photo.image.url }}"
|
|
||||||
alt="{% if photo.title %}{{ photo.title }} at {% endif %}{{ ride.name }}"
|
|
||||||
class="object-cover rounded"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
fetchpriority="low"
|
|
||||||
width="300"
|
|
||||||
height="300">
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
{% if ride.photos.count > 4 %}
|
|
||||||
<a href="{% url 'photos:ride-gallery' ride.park.slug ride.slug %}"
|
|
||||||
class="text-blue-600 hover:underline text-sm block mt-3"
|
|
||||||
aria-label="View full photo gallery of {{ ride.name }}">
|
|
||||||
View all {{ ride.photos.count }} photos
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Rides - ThrillWiki{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<!-- Version Control UI -->
|
|
||||||
{% include "history_tracking/includes/version_control_ui.html" %}
|
|
||||||
|
|
||||||
<div class="mb-8">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Rides</h1>
|
|
||||||
{% if park %}
|
|
||||||
<p class="text-gray-600 mt-2">Rides at {{ park.name }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filters -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
|
|
||||||
<form method="get" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div>
|
|
||||||
<label for="category" class="block text-sm font-medium text-gray-700">Category</label>
|
|
||||||
<select name="category" id="category" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="">All Categories</option>
|
|
||||||
{% for code, name in category_choices %}
|
|
||||||
<option value="{{ code }}" {% if category == code %}selected{% endif %}>{{ name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="status" class="block text-sm font-medium text-gray-700">Status</label>
|
|
||||||
<select name="status" id="status" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="">All Statuses</option>
|
|
||||||
{% for code, name in status_choices %}
|
|
||||||
<option value="{{ code }}" {% if status == code %}selected{% endif %}>{{ name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="manufacturer" class="block text-sm font-medium text-gray-700">Manufacturer</label>
|
|
||||||
<select name="manufacturer" id="manufacturer" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
|
|
||||||
<option value="">All Manufacturers</option>
|
|
||||||
{% for m in manufacturers %}
|
|
||||||
<option value="{{ m.id }}" {% if manufacturer == m.id %}selected{% endif %}>{{ m.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-end">
|
|
||||||
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded">
|
|
||||||
Apply Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rides Grid -->
|
|
||||||
{% if rides %}
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
||||||
{% for ride in rides %}
|
|
||||||
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
|
||||||
{% if ride.photos.exists %}
|
|
||||||
<div class="aspect-w-16 aspect-h-9">
|
|
||||||
<img src="{{ ride.photos.first.image.url }}"
|
|
||||||
alt="{{ ride.name }}"
|
|
||||||
class="object-cover w-full h-full"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
fetchpriority="low">
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex justify-between items-start mb-2">
|
|
||||||
<h2 class="text-xl font-semibold">
|
|
||||||
<a href="{{ ride.get_absolute_url }}" class="text-blue-600 hover:underline">
|
|
||||||
{{ ride.name }}
|
|
||||||
</a>
|
|
||||||
</h2>
|
|
||||||
<span class="px-2 py-1 text-xs rounded
|
|
||||||
{% if ride.status == 'OPERATING' %}
|
|
||||||
bg-green-100 text-green-800
|
|
||||||
{% elif ride.status == 'SBNO' %}
|
|
||||||
bg-yellow-100 text-yellow-800
|
|
||||||
{% elif ride.status == 'UNDER_CONSTRUCTION' %}
|
|
||||||
bg-blue-100 text-blue-800
|
|
||||||
{% else %}
|
|
||||||
bg-red-100 text-red-800
|
|
||||||
{% endif %}">
|
|
||||||
{{ ride.get_status_display }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if ride.park %}
|
|
||||||
<p class="text-gray-600 text-sm mb-2">
|
|
||||||
<a href="{{ ride.park.get_absolute_url }}" class="hover:underline">
|
|
||||||
{{ ride.park.name }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.manufacturer %}
|
|
||||||
<p class="text-gray-600 text-sm mb-2">
|
|
||||||
{{ ride.manufacturer.name }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if ride.description %}
|
|
||||||
<p class="text-gray-600 text-sm">{{ ride.description|truncatewords:30 }}</p>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Version Control Status -->
|
|
||||||
{% with version_info=ride.get_version_info %}
|
|
||||||
{% if version_info.active_branches.count > 1 %}
|
|
||||||
<div class="mt-3 text-sm">
|
|
||||||
<span class="text-yellow-600">
|
|
||||||
{{ version_info.active_branches.count }} active branches
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
{% if is_paginated %}
|
|
||||||
<div class="mt-8 flex justify-center">
|
|
||||||
<nav class="inline-flex rounded-md shadow">
|
|
||||||
{% if page_obj.has_previous %}
|
|
||||||
<a href="?page={{ page_obj.previous_page_number }}{% if request.GET.category %}&category={{ request.GET.category }}{% endif %}{% if request.GET.status %}&status={{ request.GET.status }}{% endif %}{% if request.GET.manufacturer %}&manufacturer={{ request.GET.manufacturer }}{% endif %}"
|
|
||||||
class="px-3 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
||||||
Previous
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<a href="?page={{ page_obj.next_page_number }}{% if request.GET.category %}&category={{ request.GET.category }}{% endif %}{% if request.GET.status %}&status={{ request.GET.status }}{% endif %}{% if request.GET.manufacturer %}&manufacturer={{ request.GET.manufacturer }}{% endif %}"
|
|
||||||
class="px-3 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
||||||
Next
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<p class="text-gray-600">No rides found matching your criteria.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user