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

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

View File

View File

@@ -1,3 +0,0 @@
from django.contrib import admin
# Register your models here.

View File

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

View File

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

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

@@ -1 +0,0 @@
# This file intentionally left empty - signals have been replaced with direct mixin configuration

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.)
![Version Control Panel](../static/images/docs/version-control-panel.png)
### 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)

View File

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

View File

@@ -1,9 +1,26 @@
# history_tracking/apps.py
from django.apps import AppConfig 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

View File

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

View File

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

View File

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

View File

@@ -1,237 +0,0 @@
import asyncio
import json
from typing import List, Dict, Any, Optional
from django.core.cache import cache
from django.db import models
from django.utils import timezone
from concurrent.futures import ThreadPoolExecutor
from .models import VersionTag, ChangeSet
class StructuredDiff:
def __init__(self, version1: str, version2: str):
self.version1 = version1
self.version2 = version2
self.changes: List[Dict[str, Any]] = []
self.impact_score = 0.0
self.computation_time = 0.0
self.timestamp = timezone.now()
def to_dict(self) -> Dict[str, Any]:
return {
'version1': self.version1,
'version2': self.version2,
'changes': self.changes,
'impact_score': self.impact_score,
'computation_time': self.computation_time,
'timestamp': self.timestamp.isoformat()
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'StructuredDiff':
diff = cls(data['version1'], data['version2'])
diff.changes = data['changes']
diff.impact_score = data['impact_score']
diff.computation_time = data['computation_time']
diff.timestamp = timezone.datetime.fromisoformat(data['timestamp'])
return diff
class ComparisonEngine:
"""Handles version comparison operations with background processing and caching"""
def __init__(self, chunk_size: int = 10485760): # 10MB default chunk size
self.chunk_size = chunk_size
self.executor = ThreadPoolExecutor(max_workers=4)
self.cache_ttl = 300 # 5 minutes cache TTL
async def compare_versions(self, versions: List[str]) -> List[StructuredDiff]:
"""
Compare multiple versions, processing in background and using cache
Args:
versions: List of version identifiers to compare
Returns:
List of StructuredDiff objects with comparison results
"""
if len(versions) < 2:
raise ValueError("At least two versions required for comparison")
results: List[StructuredDiff] = []
cache_misses = []
# Check cache first
for i in range(len(versions) - 1):
for j in range(i + 1, len(versions)):
cache_key = self._get_cache_key(versions[i], versions[j])
cached_result = cache.get(cache_key)
if cached_result:
results.append(StructuredDiff.from_dict(json.loads(cached_result)))
else:
cache_misses.append((versions[i], versions[j]))
# Process cache misses in background
if cache_misses:
comparison_tasks = [
self._compare_version_pair(v1, v2)
for v1, v2 in cache_misses
]
new_results = await asyncio.gather(*comparison_tasks)
results.extend(new_results)
return sorted(
results,
key=lambda x: (x.version1, x.version2)
)
def calculate_impact_score(self, diffs: List[StructuredDiff]) -> float:
"""
Calculate impact score for a set of diffs
Args:
diffs: List of StructuredDiff objects
Returns:
Float impact score (0-1)
"""
if not diffs:
return 0.0
total_score = 0.0
weights = {
'file_count': 0.3,
'change_size': 0.3,
'structural_impact': 0.4
}
for diff in diffs:
# File count impact
file_count = len(set(c['file'] for c in diff.changes))
file_score = min(file_count / 100, 1.0) # Normalize to max 100 files
# Change size impact
total_changes = sum(
len(c.get('additions', [])) + len(c.get('deletions', []))
for c in diff.changes
)
size_score = min(total_changes / 1000, 1.0) # Normalize to max 1000 lines
# Structural impact (e.g., function/class changes)
structural_changes = sum(
1 for c in diff.changes
if c.get('type') in ['function', 'class', 'schema']
)
structural_score = min(structural_changes / 10, 1.0) # Normalize to max 10 structural changes
# Weighted average
diff.impact_score = (
weights['file_count'] * file_score +
weights['change_size'] * size_score +
weights['structural_impact'] * structural_score
)
total_score += diff.impact_score
return total_score / len(diffs)
async def _compare_version_pair(self, version1: str, version2: str) -> StructuredDiff:
"""Compare two versions in background"""
start_time = timezone.now()
# Create diff structure
diff = StructuredDiff(version1, version2)
try:
# Get version data
v1_tag = await self._get_version_tag(version1)
v2_tag = await self._get_version_tag(version2)
if not v1_tag or not v2_tag:
raise ValueError("Version tag not found")
# Process in chunks if needed
changes = await self._process_version_changes(v1_tag, v2_tag)
diff.changes = changes
# Calculate impact score
diff.impact_score = self.calculate_impact_score([diff])
# Store in cache
cache_key = self._get_cache_key(version1, version2)
cache.set(
cache_key,
json.dumps(diff.to_dict()),
self.cache_ttl
)
except Exception as e:
diff.changes = [{'error': str(e)}]
diff.impact_score = 0.0
diff.computation_time = (timezone.now() - start_time).total_seconds()
return diff
async def _get_version_tag(self, version: str) -> Optional[VersionTag]:
"""Get version tag by identifier"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
self.executor,
lambda: VersionTag.objects.filter(name=version).first()
)
async def _process_version_changes(
self,
v1_tag: VersionTag,
v2_tag: VersionTag
) -> List[Dict[str, Any]]:
"""Process changes between versions in chunks"""
changes = []
# Get changesets between versions
changesets = await self._get_changesets_between(v1_tag, v2_tag)
for changeset in changesets:
# Process each change in chunks if needed
change_data = await self._process_changeset(changeset)
changes.extend(change_data)
return changes
async def _get_changesets_between(
self,
v1_tag: VersionTag,
v2_tag: VersionTag
) -> List[ChangeSet]:
"""Get all changesets between two versions"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
self.executor,
lambda: list(ChangeSet.objects.filter(
branch=v2_tag.branch,
created_at__gt=v1_tag.created_at,
created_at__lte=v2_tag.created_at,
status='applied'
).order_by('created_at'))
)
async def _process_changeset(self, changeset: ChangeSet) -> List[Dict[str, Any]]:
"""Process individual changeset for comparison"""
loop = asyncio.get_event_loop()
def process():
changes = []
instance = changeset.historical_instance
if instance:
# Get changes from historical record
diff = instance.diff_against_previous
if diff:
for field, values in diff.items():
change = {
'type': 'field',
'file': f"{instance._meta.model_name}.{field}",
'old_value': values['old'],
'new_value': values['new']
}
changes.append(change)
return changes
return await loop.run_in_executor(self.executor, process)
def _get_cache_key(self, version1: str, version2: str) -> str:
"""Generate cache key for version comparison"""
return f"version_diff:{version1}:{version2}"

View File

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

View File

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

View File

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

View File

@@ -1,123 +0,0 @@
from django.shortcuts import render, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from django.http import HttpRequest, HttpResponse, Http404
from django.template.loader import render_to_string
from django.core.exceptions import PermissionDenied
from .models import ChangeSet, HistoricalCommentThread, Comment
from .notifications import NotificationDispatcher
from .state_machine import ApprovalStateMachine
@login_required
def get_comments(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint to get comments for a specific anchor"""
anchor = request.GET.get('anchor')
if not anchor:
raise Http404("Anchor parameter is required")
thread = HistoricalCommentThread.objects.filter(anchor__id=anchor).first()
comments = thread.comments.all() if thread else []
return render(request, 'history_tracking/partials/comments_list.html', {
'comments': comments,
'anchor': anchor
})
@login_required
@require_http_methods(["POST"])
def preview_comment(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint for live comment preview"""
content = request.POST.get('content', '')
return render(request, 'history_tracking/partials/comment_preview.html', {
'content': content
})
@login_required
@require_http_methods(["POST"])
def add_comment(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint to add a comment"""
anchor = request.POST.get('anchor')
content = request.POST.get('content')
parent_id = request.POST.get('parent_id')
if not content:
return HttpResponse("Comment content is required", status=400)
thread, created = HistoricalCommentThread.objects.get_or_create(
anchor={'id': anchor},
defaults={'created_by': request.user}
)
comment = thread.comments.create(
author=request.user,
content=content,
parent_id=parent_id if parent_id else None
)
comment.extract_mentions()
# Send notifications
dispatcher = NotificationDispatcher()
dispatcher.notify_new_comment(comment, thread)
# Return updated comments list
return render(request, 'history_tracking/partials/comments_list.html', {
'comments': thread.comments.all(),
'anchor': anchor
})
@login_required
@require_http_methods(["POST"])
def approve_changes(request: HttpRequest, changeset_id: int) -> HttpResponse:
"""HTMX endpoint for approving/rejecting changes"""
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
state_machine = ApprovalStateMachine(changeset)
if not state_machine.can_user_approve(request.user):
raise PermissionDenied("You don't have permission to approve these changes")
decision = request.POST.get('decision', 'approve')
comment = request.POST.get('comment', '')
stage_id = request.POST.get('stage_id')
success = state_machine.submit_approval(
user=request.user,
decision=decision,
comment=comment,
stage_id=stage_id
)
if not success:
return HttpResponse("Failed to submit approval", status=400)
# Return updated approval status
return render(request, 'history_tracking/partials/approval_status.html', {
'changeset': changeset,
'current_stage': state_machine.get_current_stage(),
'can_approve': state_machine.can_user_approve(request.user),
'pending_approvers': state_machine.get_pending_approvers()
})
@login_required
def approval_notifications(request: HttpRequest, changeset_id: int) -> HttpResponse:
"""HTMX endpoint for live approval notifications"""
changeset = get_object_or_404(ChangeSet, pk=changeset_id)
return render(request, 'history_tracking/partials/approval_notifications.html', {
'notifications': changeset.get_recent_notifications()
})
@login_required
def get_replies(request: HttpRequest, comment_id: int) -> HttpResponse:
"""HTMX endpoint to get comment replies"""
comment = get_object_or_404(Comment, pk=comment_id)
return render(request, 'history_tracking/partials/comment_replies.html', {
'replies': comment.replies.all()
})
@login_required
def reply_form(request: HttpRequest) -> HttpResponse:
"""HTMX endpoint to get the reply form template"""
return render(request, 'history_tracking/partials/reply_form.html', {
'parent_id': request.GET.get('parent_id')
})

View File

@@ -1,519 +0,0 @@
from typing import Optional, List, Dict, Any, Tuple, Type, TypeVar, cast
from django.db import transaction
from django.core.exceptions import ValidationError
from django.utils import timezone
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import AbstractUser
from collections import Counter
import json
from .models import VersionBranch, VersionTag, ChangeSet
UserModel = TypeVar('UserModel', bound=AbstractUser)
User = cast(Type[UserModel], get_user_model())
class BranchManager:
"""Manages version control branch operations"""
@transaction.atomic
def create_branch(
self,
name: str,
parent: Optional[VersionBranch] = None,
user: Optional[UserModel] = None
) -> VersionBranch:
"""Create a new version branch"""
branch = VersionBranch.objects.create(
name=name,
parent=parent,
created_by=user,
metadata={
'created_from': parent.name if parent else 'root',
'created_at': timezone.now().isoformat()
}
)
branch.full_clean()
return branch
@transaction.atomic
def merge_branches(
self,
source: VersionBranch,
target: VersionBranch,
user: Optional[UserModel] = None
) -> Tuple[bool, List[Dict[str, Any]]]:
"""
Merge source branch into target branch
Returns: (success, conflicts)
"""
if not source.is_active or not target.is_active:
raise ValidationError("Cannot merge inactive branches")
merger = MergeStrategy()
success, conflicts = merger.auto_merge(source, target)
if success:
# Record successful merge
ChangeSet.objects.create(
branch=target,
created_by=user,
description=f"Merged branch '{source.name}' into '{target.name}'",
metadata={
'merge_source': source.name,
'merge_target': target.name,
'merged_at': timezone.now().isoformat()
},
status='applied'
)
return success, conflicts
def list_branches(self, include_inactive: bool = False) -> List[VersionBranch]:
"""Get all branches with their relationships"""
queryset = VersionBranch.objects.select_related('parent')
if not include_inactive:
queryset = queryset.filter(is_active=True)
return list(queryset)
@transaction.atomic
def acquire_lock(
self,
branch: VersionBranch,
user: UserModel,
duration: int = 48,
reason: str = ""
) -> bool:
"""
Acquire a lock on a branch
Args:
branch: The branch to lock
user: User acquiring the lock
duration: Lock duration in hours (default 48)
reason: Reason for locking
Returns:
bool: True if lock acquired, False if already locked
"""
# Check if branch is already locked
if branch.lock_status:
expires = timezone.datetime.fromisoformat(branch.lock_status['expires'])
if timezone.now() < expires:
return False
# Set lock
expiry = timezone.now() + timezone.timedelta(hours=duration)
branch.lock_status = {
'user': user.id,
'expires': expiry.isoformat(),
'reason': reason
}
# Record in history
branch.lock_history.append({
'user': user.id,
'action': 'lock',
'timestamp': timezone.now().isoformat(),
'reason': reason,
'expires': expiry.isoformat()
})
branch.save()
return True
@transaction.atomic
def release_lock(
self,
branch: VersionBranch,
user: UserModel,
force: bool = False
) -> bool:
"""
Release a lock on a branch
Args:
branch: The branch to unlock
user: User releasing the lock
force: Whether to force unlock (requires permissions)
Returns:
bool: True if lock released, False if not locked or unauthorized
"""
if not branch.lock_status:
return False
locked_by = branch.lock_status.get('user')
if not locked_by:
return False
# Check authorization
if not force and locked_by != user.id:
if not user.has_perm('history_tracking.force_unlock_branch'):
return False
# Record in history
branch.lock_history.append({
'user': user.id,
'action': 'unlock',
'timestamp': timezone.now().isoformat(),
'forced': force
})
# Clear lock
branch.lock_status = {}
branch.save()
return True
def check_lock(self, branch: VersionBranch) -> Dict[str, Any]:
"""
Check the lock status of a branch
Args:
branch: The branch to check
Returns:
dict: Lock status information
"""
if not branch.lock_status:
return {'locked': False}
expires = timezone.datetime.fromisoformat(branch.lock_status['expires'])
if timezone.now() >= expires:
# Lock has expired
branch.lock_status = {}
branch.save()
return {'locked': False}
return {
'locked': True,
'user': User.objects.get(id=branch.lock_status['user']),
'expires': expires,
'reason': branch.lock_status.get('reason', '')
}
def get_lock_history(
self,
branch: VersionBranch,
limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""
Get the lock history for a branch
Args:
branch: The branch to get history for
limit: Optional limit on number of entries
Returns:
list: Lock history entries
"""
history = branch.lock_history
if limit:
history = history[-limit:]
# Enhance history with user objects
for entry in history:
try:
entry['user_obj'] = User.objects.get(id=entry['user'])
except User.DoesNotExist:
entry['user_obj'] = None
return history
class ChangeTracker:
"""Tracks and manages changes across the system"""
@transaction.atomic
def record_change(
self,
instance: Any,
change_type: str,
branch: VersionBranch,
user: Optional[UserModel] = None,
metadata: Optional[Dict] = None
) -> ChangeSet:
"""Record a change in the system"""
if not hasattr(instance, 'history'):
raise ValueError("Instance must be a model with history tracking enabled")
# Create historical record by saving the instance
instance.save()
historical_record = instance.history.first()
if not historical_record:
raise ValueError("Failed to create historical record")
# Create changeset
content_type = ContentType.objects.get_for_model(historical_record)
changeset = ChangeSet.objects.create(
branch=branch,
created_by=user,
description=f"{change_type} operation on {instance._meta.model_name}",
metadata=metadata or {},
status='pending',
content_type=content_type,
object_id=historical_record.pk
)
return changeset
def get_changes(self, branch: VersionBranch) -> List[ChangeSet]:
"""Get all changes in a branch ordered by creation time"""
return list(ChangeSet.objects.filter(branch=branch).order_by('created_at'))
def compute_enhanced_diff(
self,
version1: Any,
version2: Any,
syntax_detect: bool = True
) -> Dict[str, Any]:
"""
Return structured diff with syntax metadata
Args:
version1: First version to compare
version2: Second version to compare
syntax_detect: Whether to detect syntax types
Returns:
Dict containing structured diff with metadata
"""
if not hasattr(version1, 'history') or not hasattr(version2, 'history'):
raise ValueError("Both versions must be history-tracked models")
# Get historical records
v1_history = version1.history.first()
v2_history = version2.history.first()
if not (v1_history and v2_history):
raise ValueError("No historical records found")
changes = {}
# Compare fields and detect syntax
for field in v2_history._meta.fields:
field_name = field.name
if field_name in [
'history_id', 'history_date', 'history_type',
'history_user_id', 'history_change_reason'
]:
continue
old_value = getattr(v1_history, field_name)
new_value = getattr(v2_history, field_name)
if old_value != new_value:
field_type = field.get_internal_type()
syntax_type = self._detect_syntax(field_type, old_value) if syntax_detect else 'text'
changes[field_name] = {
'old': str(old_value),
'new': str(new_value),
'type': field_type,
'syntax': syntax_type,
'line_numbers': self._compute_line_numbers(old_value, new_value),
'metadata': {
'comment_anchor_id': f"{v2_history.history_id}_{field_name}",
'field_type': field_type,
'content_type': v2_history._meta.model_name
}
}
# Calculate impact metrics
impact_metrics = self._calculate_impact_metrics(changes)
return {
'changes': changes,
'metadata': {
'version1_id': v1_history.history_id,
'version2_id': v2_history.history_id,
'timestamp': timezone.now().isoformat(),
'impact_score': impact_metrics['impact_score'],
'stats': impact_metrics['stats'],
'performance': {
'syntax_detection': syntax_detect,
'computed_at': timezone.now().isoformat()
}
}
}
def _detect_syntax(self, field_type: str, value: Any) -> str:
"""
Detect syntax type for field content
Args:
field_type: Django field type
value: Field value
Returns:
Detected syntax type
"""
if field_type in ['TextField', 'CharField']:
# Try to detect if it's code
if isinstance(value, str):
if value.startswith('def ') or value.startswith('class '):
return 'python'
if value.startswith('{') or value.startswith('['):
try:
json.loads(value)
return 'json'
except:
pass
if value.startswith('<!DOCTYPE') or value.startswith('<html'):
return 'html'
if value.startswith('// ') or value.startswith('function '):
return 'javascript'
syntax_map = {
'JSONField': 'json',
'FileField': 'path',
'FilePathField': 'path',
'URLField': 'url',
'EmailField': 'email',
'TextField': 'text',
'CharField': 'text'
}
return syntax_map.get(field_type, 'text')
def _compute_line_numbers(self, old_value: Any, new_value: Any) -> Dict[str, List[int]]:
"""
Compute line numbers for diff navigation
Args:
old_value: Previous value
new_value: New value
Returns:
Dict with old and new line numbers
"""
def count_lines(value):
if not isinstance(value, str):
value = str(value)
return value.count('\n') + 1
old_lines = count_lines(old_value)
new_lines = count_lines(new_value)
return {
'old': list(range(1, old_lines + 1)),
'new': list(range(1, new_lines + 1))
}
def _calculate_impact_metrics(self, changes: Dict[str, Any]) -> Dict[str, Any]:
"""
Calculate impact metrics for changes
Args:
changes: Dict of changes
Returns:
Dict with impact metrics
"""
total_lines_changed = sum(
len(c['line_numbers']['old']) + len(c['line_numbers']['new'])
for c in changes.values()
)
field_types = Counter(c['type'] for c in changes.values())
syntax_types = Counter(c['syntax'] for c in changes.values())
# Calculate impact score (0-1)
impact_weights = {
'lines_changed': 0.4,
'fields_changed': 0.3,
'complexity': 0.3
}
# Normalize metrics
normalized_lines = min(1.0, total_lines_changed / 1000) # Cap at 1000 lines
normalized_fields = min(1.0, len(changes) / 20) # Cap at 20 fields
# Complexity based on field and syntax types
complexity_score = (
len(field_types) / 10 + # Variety of field types
len(syntax_types) / 5 + # Variety of syntax types
(field_types.get('JSONField', 0) * 0.2) + # Weight complex fields higher
(syntax_types.get('python', 0) * 0.2) # Weight code changes higher
) / 2 # Normalize to 0-1
impact_score = (
impact_weights['lines_changed'] * normalized_lines +
impact_weights['fields_changed'] * normalized_fields +
impact_weights['complexity'] * complexity_score
)
return {
'impact_score': impact_score,
'stats': {
'total_lines_changed': total_lines_changed,
'fields_changed': len(changes),
'field_types': dict(field_types),
'syntax_types': dict(syntax_types)
}
}
class MergeStrategy:
"""Handles merge operations and conflict resolution"""
def auto_merge(
self,
source: VersionBranch,
target: VersionBranch
) -> Tuple[bool, List[Dict[str, Any]]]:
"""
Attempt automatic merge between branches
Returns: (success, conflicts)
"""
conflicts = []
# Get all changes since branch creation
source_changes = ChangeSet.objects.filter(
branch=source,
status='applied'
).order_by('created_at')
target_changes = ChangeSet.objects.filter(
branch=target,
status='applied'
).order_by('created_at')
# Detect conflicts
for source_change in source_changes:
for target_change in target_changes:
if self._detect_conflict(source_change, target_change):
conflicts.append({
'source_change': source_change.pk,
'target_change': target_change.pk,
'type': 'content_conflict',
'description': 'Conflicting changes detected'
})
if conflicts:
return False, conflicts
# No conflicts, apply source changes to target
for change in source_changes:
self._apply_change_to_branch(change, target)
return True, []
def _detect_conflict(self, change1: ChangeSet, change2: ChangeSet) -> bool:
"""Check if two changes conflict with each other"""
# Get historical instances
instance1 = change1.historical_instance
instance2 = change2.historical_instance
if not (instance1 and instance2):
return False
# Same model and instance ID indicates potential conflict
return (
instance1._meta.model == instance2._meta.model and
instance1.id == instance2.id
)
@transaction.atomic
def _apply_change_to_branch(
self,
change: ChangeSet,
target_branch: VersionBranch
) -> None:
"""Apply a change from one branch to another"""
# Create new changeset in target branch
new_changeset = ChangeSet.objects.create(
branch=target_branch,
description=f"Applied change from '{change.branch.name}'",
metadata={
'source_change': change.pk,
'source_branch': change.branch.name
},
status='pending',
content_type=change.content_type,
object_id=change.object_id
)
new_changeset.status = 'applied'
new_changeset.save()

View File

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

View File

@@ -1,11 +1,9 @@
# history_tracking/mixins.py # 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"""

View File

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

View File

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

View File

@@ -1,229 +0,0 @@
from django.core.mail import send_mail
from django.conf import settings
from django.template.loader import render_to_string
from django.utils import timezone
from django.contrib.auth import get_user_model
import requests
import json
from datetime import timedelta
from celery import shared_task
User = get_user_model()
class NotificationDispatcher:
"""Handles comment notifications and escalations"""
def __init__(self):
self.email_enabled = hasattr(settings, 'EMAIL_HOST')
self.slack_enabled = hasattr(settings, 'SLACK_WEBHOOK_URL')
self.sms_enabled = hasattr(settings, 'SMS_API_KEY')
def notify_new_comment(self, comment, thread):
"""Handle notification for a new comment"""
# Queue immediate notifications
self.send_in_app_notification.delay(
user_ids=self._get_thread_participants(thread),
title="New Comment",
message=f"New comment on {thread.content_object}",
link=self._get_thread_url(thread)
)
# Queue email notifications
self.send_email_notification.delay(
user_ids=self._get_thread_participants(thread),
subject=f"New comment on {thread.content_object}",
template="notifications/new_comment.html",
context={
'comment': comment,
'thread': thread,
'url': self._get_thread_url(thread)
}
)
# Schedule Slack escalation if needed
if self.slack_enabled:
self.schedule_slack_escalation.apply_async(
args=[comment.id],
countdown=24 * 3600 # 24 hours
)
def notify_mention(self, comment, mentioned_users):
"""Handle notification for @mentions"""
user_ids = [user.id for user in mentioned_users]
# Queue immediate notifications
self.send_in_app_notification.delay(
user_ids=user_ids,
title="Mentioned in Comment",
message=f"{comment.author} mentioned you in a comment",
link=self._get_comment_url(comment)
)
# Queue email notifications
self.send_email_notification.delay(
user_ids=user_ids,
subject="You were mentioned in a comment",
template="notifications/mention.html",
context={
'comment': comment,
'url': self._get_comment_url(comment)
}
)
# Queue mobile push notifications
self.send_push_notification.delay(
user_ids=user_ids,
title="New Mention",
message=f"{comment.author} mentioned you: {comment.content[:100]}..."
)
# Schedule SMS escalation if needed
if self.sms_enabled:
self.schedule_sms_escalation.apply_async(
args=[comment.id, user_ids],
countdown=12 * 3600 # 12 hours
)
def notify_resolution(self, thread, resolver):
"""Handle notification for thread resolution"""
self.send_in_app_notification.delay(
user_ids=self._get_thread_participants(thread),
title="Thread Resolved",
message=f"Thread resolved by {resolver}",
link=self._get_thread_url(thread)
)
@shared_task
def send_in_app_notification(user_ids, title, message, link):
"""Send in-app notification to users"""
from .models import InAppNotification
for user_id in user_ids:
InAppNotification.objects.create(
user_id=user_id,
title=title,
message=message,
link=link
)
@shared_task
def send_email_notification(user_ids, subject, template, context):
"""Send email notification to users"""
if not settings.EMAIL_HOST:
return
users = User.objects.filter(id__in=user_ids)
for user in users:
if not user.email:
continue
html_content = render_to_string(template, {
'user': user,
**context
})
send_mail(
subject=subject,
message='',
html_message=html_content,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email]
)
@shared_task
def send_push_notification(user_ids, title, message):
"""Send mobile push notification"""
from .models import PushToken
tokens = PushToken.objects.filter(
user_id__in=user_ids,
active=True
)
if not tokens:
return
# Implementation depends on push notification service
# Example using Firebase:
try:
requests.post(
settings.FIREBASE_FCM_URL,
headers={
'Authorization': f'key={settings.FIREBASE_SERVER_KEY}',
'Content-Type': 'application/json'
},
json={
'registration_ids': [t.token for t in tokens],
'notification': {
'title': title,
'body': message
}
}
)
except Exception as e:
print(f"Push notification failed: {e}")
@shared_task
def schedule_slack_escalation(comment_id):
"""Send Slack DM escalation for unread comments"""
from .models import Comment
try:
comment = Comment.objects.get(id=comment_id)
if not comment.read_by.exists():
# Send Slack message
requests.post(
settings.SLACK_WEBHOOK_URL,
json={
'text': (
f"Unread comment needs attention:\n"
f"{comment.content}\n"
f"View: {self._get_comment_url(comment)}"
)
}
)
except Exception as e:
print(f"Slack escalation failed: {e}")
@shared_task
def schedule_sms_escalation(comment_id, user_ids):
"""Send SMS escalation for unread mentions"""
from .models import Comment
try:
comment = Comment.objects.get(id=comment_id)
users = User.objects.filter(id__in=user_ids)
for user in users:
if not user.phone_number:
continue
if not comment.read_by.filter(id=user.id).exists():
# Send SMS using Twilio or similar service
requests.post(
settings.SMS_API_URL,
headers={'Authorization': f'Bearer {settings.SMS_API_KEY}'},
json={
'to': user.phone_number,
'message': (
f"You were mentioned in a comment that needs attention. "
f"View: {self._get_comment_url(comment)}"
)
}
)
except Exception as e:
print(f"SMS escalation failed: {e}")
def _get_thread_participants(self, thread):
"""Get IDs of all participants in a thread"""
return list(set(
[thread.created_by_id] +
list(thread.comments.values_list('author_id', flat=True))
))
def _get_thread_url(self, thread):
"""Generate URL for thread"""
return f"/version-control/comments/thread/{thread.id}/"
def _get_comment_url(self, comment):
"""Generate URL for specific comment"""
return f"{self._get_thread_url(comment.thread)}#comment-{comment.id}"

View File

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

View File

@@ -1,194 +0,0 @@
from typing import List, Dict, Any, Optional
from django.contrib.auth import get_user_model
from django.utils import timezone
from .models import ChangeSet
User = get_user_model()
class ApprovalStage:
def __init__(self, stage_id: int, name: str, required_roles: List[str]):
self.id = stage_id
self.name = name
self.required_roles = required_roles
self.approvers: List[Dict[str, Any]] = []
self.status = 'pending' # pending, approved, rejected
self.completed_at = None
def add_approver(self, user: User, decision: str, comment: str = "") -> None:
"""Add an approver's decision to this stage"""
self.approvers.append({
'user_id': user.id,
'username': user.username,
'decision': decision,
'comment': comment,
'timestamp': timezone.now().isoformat()
})
def is_approved(self, policy: str = 'unanimous') -> bool:
"""Check if stage is approved based on policy"""
if not self.approvers:
return False
approve_count = sum(1 for a in self.approvers if a['decision'] == 'approve')
if policy == 'unanimous':
return approve_count == len(self.required_roles)
else: # majority
return approve_count > len(self.required_roles) / 2
class ApprovalStateMachine:
"""Manages the state transitions for change approval workflow"""
def __init__(self, changeset: ChangeSet):
self.changeset = changeset
self.stages: List[ApprovalStage] = []
self.current_stage_index = 0
self.policy = changeset.approval_policy
self._load_state()
def _load_state(self) -> None:
"""Load the current state from changeset approval_state"""
if not self.changeset.approval_state:
return
for stage_data in self.changeset.approval_state:
stage = ApprovalStage(
stage_data['id'],
stage_data['name'],
stage_data['required_roles']
)
stage.approvers = stage_data.get('approvers', [])
stage.status = stage_data.get('status', 'pending')
stage.completed_at = stage_data.get('completed_at')
self.stages.append(stage)
# Find current stage
self.current_stage_index = next(
(i for i, s in enumerate(self.stages) if s.status == 'pending'),
len(self.stages) - 1
)
def initialize_workflow(self, stages_config: List[Dict[str, Any]]) -> None:
"""Set up initial approval workflow stages"""
self.stages = [
ApprovalStage(
i,
stage['name'],
stage['required_roles']
) for i, stage in enumerate(stages_config)
]
self._save_state()
def submit_approval(
self,
user: User,
decision: str,
comment: str = "",
stage_id: Optional[int] = None
) -> bool:
"""
Submit an approval decision
Args:
user: The user submitting approval
decision: 'approve' or 'reject'
comment: Optional comment
stage_id: Optional specific stage ID (for parallel approval)
Returns:
bool: True if submission was accepted
"""
if self.changeset.status != 'pending_approval':
return False
if self.policy == 'sequential':
stage = self.stages[self.current_stage_index]
else: # parallel
if stage_id is None:
return False
stage = next((s for s in self.stages if s.id == stage_id), None)
if not stage:
return False
# Check if user has required role
user_roles = set(user.groups.values_list('name', flat=True))
if not any(role in user_roles for role in stage.required_roles):
return False
# Add decision
stage.add_approver(user, decision, comment)
# Update stage status
if stage.is_approved(self.policy):
stage.status = 'approved'
stage.completed_at = timezone.now().isoformat()
if self.policy == 'sequential':
self._advance_stage()
elif decision == 'reject':
stage.status = 'rejected'
stage.completed_at = timezone.now().isoformat()
self.changeset.status = 'rejected'
self.changeset.save()
self._save_state()
return True
def _advance_stage(self) -> None:
"""Move to next stage if available"""
if self.current_stage_index < len(self.stages) - 1:
self.current_stage_index += 1
else:
# All stages approved
self.changeset.status = 'approved'
self.changeset.save()
def _save_state(self) -> None:
"""Save current state to changeset"""
self.changeset.approval_state = [
{
'id': stage.id,
'name': stage.name,
'required_roles': stage.required_roles,
'approvers': stage.approvers,
'status': stage.status,
'completed_at': stage.completed_at
} for stage in self.stages
]
self.changeset.save()
def get_current_stage(self) -> Optional[ApprovalStage]:
"""Get the current active stage"""
if not self.stages:
return None
return self.stages[self.current_stage_index]
def get_stage_by_id(self, stage_id: int) -> Optional[ApprovalStage]:
"""Get a specific stage by ID"""
return next((s for s in self.stages if s.id == stage_id), None)
def get_pending_approvers(self) -> List[str]:
"""Get list of roles that still need to approve the current stage"""
current_stage = self.get_current_stage()
if not current_stage:
return []
approved_by = {a['user_id'] for a in current_stage.approvers}
return [
role for role in current_stage.required_roles
if not any(
user.id in approved_by
for user in User.objects.filter(groups__name=role)
)
]
def can_user_approve(self, user: User) -> bool:
"""Check if user can approve the current stage"""
current_stage = self.get_current_stage()
if not current_stage:
return False
# Check if user already approved
if any(a['user_id'] == user.id for a in current_stage.approvers):
return False
# Check if user has required role
user_roles = set(user.groups.values_list('name', flat=True))
return any(role in user_roles for role in current_stage.required_roles)

View File

@@ -1,174 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="approval-status">
<div class="status-header">
<h2>Approval Status</h2>
<div class="changeset-info">
<span class="changeset-id">Changeset #{{ changeset.pk }}</span>
<span class="status-badge {{ changeset.status }}">{{ changeset.status|title }}</span>
</div>
</div>
{% if changeset.description %}
<div class="changeset-description">
{{ changeset.description }}
</div>
{% endif %}
{% if current_stage %}
<div class="current-stage">
<h3>Current Stage: {{ current_stage.name }}</h3>
<div class="required-roles">
<h4>Required Approvers:</h4>
<ul class="role-list">
{% for role in current_stage.required_roles %}
<li class="role-item">{{ role }}</li>
{% endfor %}
</ul>
</div>
<div class="approvers">
<h4>Current Approvers:</h4>
{% if current_stage.approvers %}
<ul class="approvers-list">
{% for approver in current_stage.approvers %}
<li class="approver-item">
<div class="approver-info">
<span class="approver-name">{{ approver.user }}</span>
<span class="approval-date">{{ approver.timestamp|date:"Y-m-d H:i" }}</span>
</div>
{% if approver.comment %}
<div class="approval-comment">
{{ approver.comment }}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="no-approvers">No approvals yet</p>
{% endif %}
</div>
{% if can_approve %}
<div class="approval-actions">
<form method="post" action="{% url 'history_tracking:approve_changes' changeset.pk %}">
{% csrf_token %}
<div class="form-group">
<label for="comment">Comment (optional):</label>
<textarea name="comment" id="comment" rows="3" class="form-control"></textarea>
</div>
{% if current_stage.id %}
<input type="hidden" name="stage_id" value="{{ current_stage.id }}">
{% endif %}
<div class="approval-buttons">
<button type="submit" name="decision" value="approve" class="btn btn-success">
Approve Changes
</button>
<button type="submit" name="decision" value="reject" class="btn btn-danger">
Reject Changes
</button>
</div>
</form>
</div>
{% endif %}
</div>
{% endif %}
<div class="approval-history">
<h3>Approval History</h3>
{% if changeset.approval_history %}
<div class="history-timeline">
{% for entry in changeset.approval_history %}
<div class="history-entry">
<div class="entry-header">
<span class="entry-stage">{{ entry.stage_name }}</span>
<span class="entry-date">{{ entry.timestamp|date:"Y-m-d H:i" }}</span>
</div>
<div class="entry-content">
<span class="entry-user">{{ entry.user }}</span>
<span class="entry-action">{{ entry.action|title }}</span>
{% if entry.comment %}
<div class="entry-comment">
{{ entry.comment }}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="no-history">No approval history yet</p>
{% endif %}
</div>
{% if pending_approvers %}
<div class="pending-approvers">
<h3>Waiting for Approval From:</h3>
<ul class="pending-list">
{% for role in pending_approvers %}
<li class="pending-role">{{ role }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if messages %}
<div class="messages">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}
{% block extra_css %}
<style>
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.status-badge.pending_approval { background-color: #fef3c7; color: #92400e; }
.status-badge.approved { background-color: #dcfce7; color: #166534; }
.status-badge.rejected { background-color: #fee2e2; color: #991b1b; }
.history-timeline {
border-left: 2px solid #e5e7eb;
margin-left: 1rem;
padding-left: 1rem;
}
.history-entry {
position: relative;
margin-bottom: 1.5rem;
}
.history-entry::before {
content: '';
position: absolute;
left: -1.5rem;
top: 0.5rem;
width: 1rem;
height: 1rem;
background: #fff;
border: 2px solid #3b82f6;
border-radius: 50%;
}
.approval-actions {
margin-top: 2rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
}
</style>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,51 +0,0 @@
{% if notifications %}
<div class="approval-notifications" id="approval-notifications">
{% for notification in notifications %}
<div class="notification-item {% if notification.is_new %}new{% endif %}"
{% if notification.is_new %}
hx-swap-oob="afterbegin:.approval-notifications"
{% endif %}>
<div class="notification-header">
<span class="notification-type">{{ notification.type }}</span>
<span class="notification-time">{{ notification.timestamp|timesince }} ago</span>
</div>
<div class="notification-content">
{% if notification.type == 'approval' %}
<span class="approver">{{ notification.user }}</span>
{{ notification.action }} the changes
{% if notification.stage %}
in stage <strong>{{ notification.stage }}</strong>
{% endif %}
{% elif notification.type == 'comment' %}
<span class="commenter">{{ notification.user }}</span>
commented on the changes
{% elif notification.type == 'stage_change' %}
Moved to stage <strong>{{ notification.stage }}</strong>
{% endif %}
{% if notification.comment %}
<div class="notification-comment">
"{{ notification.comment }}"
</div>
{% endif %}
</div>
{% if notification.actions %}
<div class="notification-actions">
{% for action in notification.actions %}
<button class="btn btn-sm btn-link"
hx-post="{{ action.url }}"
hx-trigger="click"
hx-target="#approval-status-container">
{{ action.label }}
</button>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="no-notifications" id="approval-notifications">
No recent notifications
</div>
{% endif %}

View File

@@ -1,102 +0,0 @@
{% if current_stage %}
<div id="approval-status-container">
<div class="current-stage-info">
<h3>Current Stage: {{ current_stage.name }}</h3>
<div class="required-roles">
<h4>Required Approvers:</h4>
<ul class="role-list">
{% for role in current_stage.required_roles %}
<li class="role-item {% if role in pending_approvers %}pending{% endif %}">
{{ role }}
</li>
{% endfor %}
</ul>
</div>
<div class="approvers">
<h4>Current Approvers:</h4>
{% if current_stage.approvers %}
<ul class="approvers-list">
{% for approver in current_stage.approvers %}
<li class="approver-item">
<div class="approver-info">
<span class="approver-name">{{ approver.user }}</span>
<span class="approval-date">{{ approver.timestamp|date:"Y-m-d H:i" }}</span>
</div>
{% if approver.comment %}
<div class="approval-comment">
{{ approver.comment }}
</div>
{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="no-approvers">No approvals yet</p>
{% endif %}
</div>
{% if can_approve %}
<div class="approval-actions">
<form hx-post="{% url 'history_tracking:approve_changes' changeset.pk %}"
hx-trigger="submit"
hx-target="#approval-status-container"
hx-swap="outerHTML">
{% csrf_token %}
<div class="form-group">
<label for="comment">Comment (optional):</label>
<textarea name="comment"
id="comment"
rows="3"
class="form-control"
hx-post="{% url 'history_tracking:preview_comment' %}"
hx-trigger="keyup changed delay:500ms"
hx-target="#comment-preview"></textarea>
<div id="comment-preview" class="comment-preview"></div>
</div>
{% if current_stage.id %}
<input type="hidden" name="stage_id" value="{{ current_stage.id }}">
{% endif %}
<div class="approval-buttons">
<button type="submit"
name="decision"
value="approve"
class="btn btn-success"
{% if not can_approve %}disabled{% endif %}
hx-indicator="#approve-indicator">
Approve Changes
<span class="htmx-indicator" id="approve-indicator">
<span class="spinner"></span>
</span>
</button>
<button type="submit"
name="decision"
value="reject"
class="btn btn-danger"
{% if not can_approve %}disabled{% endif %}
hx-indicator="#reject-indicator">
Reject Changes
<span class="htmx-indicator" id="reject-indicator">
<span class="spinner"></span>
</span>
</button>
</div>
</form>
</div>
{% endif %}
{% if messages %}
<div class="approval-messages"
hx-swap-oob="true">
{% for message in messages %}
<div class="alert {% if message.tags %}alert-{{ message.tags }}{% endif %}"
role="alert">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endif %}

View File

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

View File

@@ -1,27 +0,0 @@
{% if replies %}
<div class="replies-list">
{% for reply in replies %}
<div class="reply" id="comment-{{ reply.id }}">
<div class="reply-header">
<span class="reply-author">{{ reply.author }}</span>
<span class="reply-date">{{ reply.created_at|date:"Y-m-d H:i" }}</span>
</div>
<div class="reply-content">
{{ reply.content }}
</div>
{% if user.has_perm 'history_tracking.add_comment' %}
<div class="reply-actions">
<button class="btn btn-sm btn-link"
hx-get="{% url 'history_tracking:reply_form' %}?parent_id={{ reply.id }}"
hx-trigger="click"
hx-target="#reply-form-{{ reply.id }}"
hx-swap="innerHTML">
Reply
</button>
</div>
<div id="reply-form-{{ reply.id }}"></div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}

View File

@@ -1,35 +0,0 @@
{% if comments %}
<div class="comments-list">
{% for comment in comments %}
<div class="comment" id="comment-{{ comment.id }}"
{% if forloop.first %}hx-swap-oob="true"{% endif %}>
<div class="comment-header">
<span class="comment-author">{{ comment.author }}</span>
<span class="comment-date">{{ comment.created_at|date:"Y-m-d H:i" }}</span>
</div>
<div class="comment-content">{{ comment.content }}</div>
{% if user.has_perm 'history_tracking.add_comment' %}
<div class="comment-actions">
<button class="btn btn-sm btn-link"
hx-get="{% url 'history_tracking:reply_form' %}"
hx-trigger="click"
hx-target="#reply-form-{{ comment.id }}"
hx-swap="innerHTML">
Reply
</button>
</div>
<div id="reply-form-{{ comment.id }}"></div>
{% endif %}
{% if comment.replies.exists %}
<div class="comment-replies"
hx-get="{% url 'history_tracking:get_replies' comment.id %}"
hx-trigger="load">
<div class="htmx-indicator">Loading replies...</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="no-comments">No comments yet</p>
{% endif %}

View File

@@ -1,33 +0,0 @@
<div class="reply-form-container">
<form hx-post="{% url 'history_tracking:add_comment' %}"
hx-trigger="submit"
hx-target="closest .comments-list"
hx-swap="innerHTML"
class="reply-form">
{% csrf_token %}
<input type="hidden" name="parent_id" value="{{ parent_id }}">
<input type="hidden" name="anchor" value="{{ anchor }}">
<div class="form-group">
<textarea name="content"
class="form-control reply-input"
placeholder="Write a reply..."
rows="2"
hx-post="{% url 'history_tracking:preview_comment' %}"
hx-trigger="keyup changed delay:500ms"
hx-target="#reply-preview-{{ parent_id }}"></textarea>
<div id="reply-preview-{{ parent_id }}" class="reply-preview"></div>
</div>
<div class="form-actions">
<button type="button"
class="btn btn-sm btn-light"
onclick="this.closest('.reply-form-container').remove()">
Cancel
</button>
<button type="submit"
class="btn btn-sm btn-primary"
hx-disable-if="querySelector('.reply-input').value === ''">
Submit Reply
</button>
</div>
</form>
</div>

View File

@@ -1,170 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="version-comparison">
<div class="comparison-header">
<h2>Version Comparison</h2>
{# Version Selection Form with HTMX #}
<div class="version-select-form">
<div class="version-selectors">
<div class="version-select">
<label for="version1">First Version</label>
<select name="version1" id="version1"
hx-get="{% url 'history_tracking:version_comparison' %}"
hx-trigger="change"
hx-target="#comparison-results"
hx-indicator=".loading-indicator">
{% for version in versions %}
<option value="{{ version.id }}" {% if version.id == selected_version1 %}selected{% endif %}>
{{ version.name }} ({{ version.created_at|date:"Y-m-d H:i" }})
</option>
{% endfor %}
</select>
</div>
<div class="version-select">
<label for="version2">Second Version</label>
<select name="version2" id="version2"
hx-get="{% url 'history_tracking:version_comparison' %}"
hx-trigger="change"
hx-target="#comparison-results"
hx-indicator=".loading-indicator">
{% for version in versions %}
<option value="{{ version.id }}" {% if version.id == selected_version2 %}selected{% endif %}>
{{ version.name }} ({{ version.created_at|date:"Y-m-d H:i" }})
</option>
{% endfor %}
</select>
</div>
</div>
<div class="loading-indicator htmx-indicator">Loading comparison...</div>
</div>
</div>
{% if diff_result %}
<div class="comparison-results">
<div class="diff-summary">
<h3>Changes Summary</h3>
<div class="diff-stats">
<div class="stat-item">
<span class="stat-label">Files Changed:</span>
<span class="stat-value">{{ diff_result.stats.total_files }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Lines Changed:</span>
<span class="stat-value">{{ diff_result.stats.total_lines }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Impact Score:</span>
<span class="stat-value">{{ diff_result.impact_score|floatformat:2 }}</span>
</div>
</div>
</div>
<div class="changes-list">
{% for change in diff_result.changes %}
<div class="change-item">
<div class="change-header">
<h4>{{ change.field }}</h4>
<span class="change-type">{{ change.type }}</span>
</div>
<div class="diff-view {% if change.syntax_type %}language-{{ change.syntax_type }}{% endif %}">
<div class="old-version">
<div class="version-header">Previous Version</div>
<pre><code>{{ change.old }}</code></pre>
</div>
<div class="new-version">
<div class="version-header">New Version</div>
<pre><code>{{ change.new }}</code></pre>
</div>
</div>
{% if user.has_perm 'history_tracking.add_comment' %}
<div class="comment-section"
id="comments-{{ change.metadata.comment_anchor_id }}"
hx-get="{% url 'history_tracking:get_comments' %}"
hx-trigger="load, commentAdded from:body"
hx-vals='{"anchor": "{{ change.metadata.comment_anchor_id }}"}'>
<div class="htmx-indicator">Loading comments...</div>
</div>
<div class="comment-form-container">
<form hx-post="{% url 'history_tracking:add_comment' %}"
hx-trigger="submit"
hx-target="#comments-{{ change.metadata.comment_anchor_id }}"
hx-swap="innerHTML"
class="comment-form">
{% csrf_token %}
<input type="hidden" name="anchor" value="{{ change.metadata.comment_anchor_id }}">
<textarea name="content"
placeholder="Add a comment..."
hx-post="{% url 'history_tracking:preview_comment' %}"
hx-trigger="keyup changed delay:500ms"
hx-target="#comment-preview-{{ change.metadata.comment_anchor_id }}"
class="comment-input"></textarea>
<div id="comment-preview-{{ change.metadata.comment_anchor_id }}"
class="comment-preview"></div>
<button type="submit"
class="btn btn-sm btn-primary"
hx-disable-if="querySelector('.comment-input').value === ''">
Comment
</button>
</form>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% if diff_result.changes.has_other_pages %}
<div class="pagination">
<span class="step-links">
{% if diff_result.changes.has_previous %}
<a href="?page=1">&laquo; first</a>
<a href="?page={{ diff_result.changes.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ diff_result.changes.number }} of {{ diff_result.changes.paginator.num_pages }}
</span>
{% if diff_result.changes.has_next %}
<a href="?page={{ diff_result.changes.next_page_number }}">next</a>
<a href="?page={{ diff_result.changes.paginator.num_pages }}">last &raquo;</a>
{% endif %}
</span>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% comment %}
Only include minimal JavaScript for necessary interactivity
{% endcomment %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Handle syntax highlighting
document.querySelectorAll('pre code').forEach((block) => {
const language = block.parentElement.parentElement.dataset.language;
if (language) {
block.classList.add(`language-${language}`);
hljs.highlightElement(block);
}
});
// Simple form validation
document.querySelector('.version-select-form').addEventListener('submit', function(e) {
const v1 = document.getElementById('version1').value;
const v2 = document.getElementById('version2').value;
if (v1 === v2) {
e.preventDefault();
alert('Please select different versions to compare');
}
});
});
</script>
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,50 +0,0 @@
from django.urls import path
from . import views, htmx_views
app_name = 'history_tracking'
urlpatterns = [
# Main page views
path('compare/',
views.version_comparison,
name='version_comparison'
),
path('approval-status/<int:changeset_id>/',
views.approval_status,
name='approval_status'
),
path('submit-approval/<int:changeset_id>/',
views.submit_for_approval,
name='submit_for_approval'
),
# HTMX endpoints
path('htmx/comments/get/',
htmx_views.get_comments,
name='get_comments'
),
path('htmx/comments/preview/',
htmx_views.preview_comment,
name='preview_comment'
),
path('htmx/comments/add/',
htmx_views.add_comment,
name='add_comment'
),
path('htmx/approve/<int:changeset_id>/',
htmx_views.approve_changes,
name='approve_changes'
),
path('htmx/notifications/<int:changeset_id>/',
htmx_views.approval_notifications,
name='approval_notifications'
),
path('htmx/comments/replies/<int:comment_id>/',
htmx_views.get_replies,
name='get_replies'
),
path('htmx/comments/reply-form/',
htmx_views.reply_form,
name='reply_form'
),
]

View File

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

View File

@@ -1,144 +1,3 @@
from django.shortcuts import render, get_object_or_404 from django.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
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -125,4 +101,15 @@
- Update user guide with new features - Update user guide with new features
- 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 "&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;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

View File

@@ -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
@@ -270,10 +162,4 @@ class ViewTests(TestCase):
- Code review - Code review
- Testing verification - Testing verification
- Documentation update - Documentation update
- Deployment planning - Deployment planning
4. Performance Review
- Query analysis
- Cache efficiency
- Load testing
- Scalability verification

View File

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

View File

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

View File

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

View File

@@ -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",
] ]

View File

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

View File

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

View File

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

View File

@@ -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()
@@ -43,69 +37,31 @@ class Review(HistoricalModel, CommentableMixin, PhotoableModel):
related_name='moderated_reviews' related_name='moderated_reviews'
) )
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,
if current_branch: related_name='images'
# Save in the context of the current branch )
super().save(*args, **kwargs) image = models.ImageField(upload_to='review_images/')
else: caption = models.CharField(max_length=200, blank=True)
# If no branch context, save in main branch order = models.PositiveIntegerField(default=0)
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: class Meta:
"""Get version control information for this review and its reviewed object""" ordering = ['order']
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 def __str__(self):
reviewed_object_branch = None return f"Image {self.order + 1} for {self.review}"
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(

View File

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

View File

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

View File

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

View File

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

View File

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