mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:11:10 -05:00
Add OWASP compliance mapping and security test case templates, and document version control implementation phases
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
from django.db import models
|
||||
from django.utils.text import slugify
|
||||
from django.urls import reverse
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from history_tracking.models import HistoricalSlug
|
||||
|
||||
class Company(models.Model):
|
||||
class Company(HistoricalModel):
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
website = models.URLField(blank=True)
|
||||
@@ -29,7 +32,47 @@ class Company(models.Model):
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if not self.slug:
|
||||
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
|
||||
def get_by_slug(cls, slug: str) -> Tuple['Company', bool]:
|
||||
@@ -48,7 +91,7 @@ class Company(models.Model):
|
||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||
raise cls.DoesNotExist()
|
||||
|
||||
class Manufacturer(models.Model):
|
||||
class Manufacturer(HistoricalModel):
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
website = models.URLField(blank=True)
|
||||
@@ -70,7 +113,47 @@ class Manufacturer(models.Model):
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if not self.slug:
|
||||
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
|
||||
def get_by_slug(cls, slug: str) -> Tuple['Manufacturer', bool]:
|
||||
@@ -89,7 +172,7 @@ class Manufacturer(models.Model):
|
||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||
raise cls.DoesNotExist()
|
||||
|
||||
class Designer(models.Model):
|
||||
class Designer(HistoricalModel):
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(max_length=255, unique=True)
|
||||
website = models.URLField(blank=True)
|
||||
@@ -110,7 +193,47 @@ class Designer(models.Model):
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if not self.slug:
|
||||
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
|
||||
def get_by_slug(cls, slug: str) -> Tuple['Designer', bool]:
|
||||
|
||||
137
companies/templates/companies/company_detail.html
Normal file
137
companies/templates/companies/company_detail.html
Normal file
@@ -0,0 +1,137 @@
|
||||
{% 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 %}
|
||||
136
companies/templates/companies/company_list.html
Normal file
136
companies/templates/companies/company_list.html
Normal file
@@ -0,0 +1,136 @@
|
||||
{% 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 %}
|
||||
154
companies/templates/companies/designer_detail.html
Normal file
154
companies/templates/companies/designer_detail.html
Normal file
@@ -0,0 +1,154 @@
|
||||
{% 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 %}
|
||||
146
companies/templates/companies/designer_list.html
Normal file
146
companies/templates/companies/designer_list.html
Normal file
@@ -0,0 +1,146 @@
|
||||
{% 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 %}
|
||||
188
companies/templates/companies/manufacturer_detail.html
Normal file
188
companies/templates/companies/manufacturer_detail.html
Normal file
@@ -0,0 +1,188 @@
|
||||
{% 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 %}
|
||||
162
companies/templates/companies/manufacturer_list.html
Normal file
162
companies/templates/companies/manufacturer_list.html
Normal file
@@ -0,0 +1,162 @@
|
||||
{% 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 %}
|
||||
320
docs/version_control_api.md
Normal file
320
docs/version_control_api.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# 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
|
||||
184
docs/version_control_user_guide.md
Normal file
184
docs/version_control_user_guide.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# Version Control User Guide
|
||||
|
||||
## Introduction
|
||||
The version control system allows you to track changes, create branches, and merge content updates across ThrillWiki. This guide explains how to use the version control features effectively.
|
||||
|
||||
## Basic Concepts
|
||||
|
||||
### Branches
|
||||
A branch is a separate line of development that allows you to make changes without affecting the main content. Think of it like a draft version of your content.
|
||||
|
||||
- **Main Branch**: The default branch containing the live, published content
|
||||
- **Feature Branches**: Temporary branches for developing new content or making significant changes
|
||||
- **Active Branch**: The branch you're currently working on
|
||||
|
||||
### Changes
|
||||
Changes represent modifications to content:
|
||||
- Adding new information
|
||||
- Updating existing content
|
||||
- Removing outdated content
|
||||
|
||||
### Merging
|
||||
Merging combines changes from one branch into another, typically from a feature branch back into the main branch.
|
||||
|
||||
## Using Version Control
|
||||
|
||||
### 1. Version Control Panel
|
||||
The version control panel appears at the top of editable pages and shows:
|
||||
- Current branch
|
||||
- Branch selector
|
||||
- Action buttons (Create Branch, Merge, etc.)
|
||||
|
||||

|
||||
|
||||
### 2. Creating a Branch
|
||||
1. Click "Create Branch" in the version control panel
|
||||
2. Enter a branch name (e.g., "update-park-details")
|
||||
3. Add an optional description
|
||||
4. Click "Create"
|
||||
|
||||
Branch naming conventions:
|
||||
- Use lowercase letters
|
||||
- Separate words with hyphens
|
||||
- Be descriptive (e.g., "add-new-rides", "update-park-history")
|
||||
|
||||
### 3. Switching Branches
|
||||
1. Open the branch selector in the version control panel
|
||||
2. Select the desired branch
|
||||
3. Click "Switch Branch"
|
||||
|
||||
Note: You'll see a warning if you have unsaved changes.
|
||||
|
||||
### 4. Making Changes
|
||||
1. Ensure you're on the correct branch
|
||||
2. Edit content normally
|
||||
3. Save changes
|
||||
4. Changes are tracked automatically
|
||||
|
||||
The version control panel shows:
|
||||
- Number of changes
|
||||
- Last update time
|
||||
- Change status
|
||||
|
||||
### 5. Viewing History
|
||||
1. Click "History" in the version control panel
|
||||
2. See a list of changes with:
|
||||
- Timestamp
|
||||
- Author
|
||||
- Description
|
||||
- Branch
|
||||
3. Click any change to see details
|
||||
|
||||
### 6. Merging Changes
|
||||
1. Switch to the target branch (usually main)
|
||||
2. Click "Merge" in the version control panel
|
||||
3. Select the source branch
|
||||
4. Review changes
|
||||
5. Click "Merge Changes"
|
||||
|
||||
### 7. Handling Conflicts
|
||||
If conflicts occur during merging:
|
||||
1. The conflict resolution dialog appears
|
||||
2. Review conflicting changes
|
||||
3. Choose which version to keep or combine them
|
||||
4. Click "Resolve Conflicts"
|
||||
5. Complete the merge
|
||||
|
||||
## Best Practices
|
||||
|
||||
### When to Create a Branch
|
||||
Create a new branch when:
|
||||
- Making substantial content updates
|
||||
- Adding new sections
|
||||
- Reorganizing information
|
||||
- Testing new features
|
||||
|
||||
### Branch Management
|
||||
- Keep branches focused on specific tasks
|
||||
- Delete branches after merging
|
||||
- Regular merge changes from main to stay current
|
||||
- Use descriptive branch names
|
||||
|
||||
### Change Management
|
||||
- Make atomic, related changes
|
||||
- Write clear change descriptions
|
||||
- Review changes before merging
|
||||
- Test content in preview mode
|
||||
|
||||
### Collaboration
|
||||
- Communicate branch purpose to team members
|
||||
- Coordinate on shared branches
|
||||
- Review changes before merging
|
||||
- Resolve conflicts together when needed
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Updating a Park Page
|
||||
1. Create a branch (e.g., "update-park-info")
|
||||
2. Make changes to park information
|
||||
3. Preview changes
|
||||
4. Merge back to main when ready
|
||||
|
||||
### Adding New Rides
|
||||
1. Create a branch (e.g., "add-new-rides-2025")
|
||||
2. Add ride information
|
||||
3. Add photos and details
|
||||
4. Review and merge
|
||||
|
||||
### Content Reorganization
|
||||
1. Create a branch (e.g., "reorganize-sections")
|
||||
2. Rearrange content
|
||||
3. Update navigation
|
||||
4. Test thoroughly
|
||||
5. Merge changes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### Unable to Create Branch
|
||||
- Check permissions
|
||||
- Verify branch name is valid
|
||||
- Ensure no conflicts with existing branches
|
||||
|
||||
#### Merge Conflicts
|
||||
1. Don't panic! Conflicts are normal
|
||||
2. Review both versions carefully
|
||||
3. Choose the best content
|
||||
4. Test after resolving
|
||||
|
||||
#### Lost Changes
|
||||
1. Check branch history
|
||||
2. Review recent changes
|
||||
3. Contact administrator if needed
|
||||
|
||||
### Getting Help
|
||||
- Click the "Help" button in the version control panel
|
||||
- Contact administrators for complex issues
|
||||
- Check documentation for guidance
|
||||
|
||||
## Version Control Status Icons
|
||||
|
||||
| Icon | Meaning |
|
||||
|------|---------|
|
||||
| 🟢 | Current branch |
|
||||
| 🔄 | Pending changes |
|
||||
| ⚠️ | Merge conflicts |
|
||||
| ✅ | Successfully merged |
|
||||
| 🔒 | Protected branch |
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Action | Shortcut |
|
||||
|--------|----------|
|
||||
| Switch Branch | Ctrl/Cmd + B |
|
||||
| Create Branch | Ctrl/Cmd + Shift + B |
|
||||
| View History | Ctrl/Cmd + H |
|
||||
| Merge Branch | Ctrl/Cmd + M |
|
||||
| Save Changes | Ctrl/Cmd + S |
|
||||
|
||||
## Additional Resources
|
||||
- [API Documentation](version_control_api.md)
|
||||
- [Technical Documentation](technical_architecture.md)
|
||||
- [Video Tutorials](https://wiki.thrillwiki.com/tutorials)
|
||||
- [Community Forums](https://community.thrillwiki.com)
|
||||
195
history_tracking/batch.py
Normal file
195
history_tracking/batch.py
Normal file
@@ -0,0 +1,195 @@
|
||||
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
|
||||
223
history_tracking/caching.py
Normal file
223
history_tracking/caching.py
Normal file
@@ -0,0 +1,223 @@
|
||||
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
|
||||
)
|
||||
248
history_tracking/cleanup.py
Normal file
248
history_tracking/cleanup.py
Normal file
@@ -0,0 +1,248 @@
|
||||
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
|
||||
202
history_tracking/monitoring.py
Normal file
202
history_tracking/monitoring.py
Normal file
@@ -0,0 +1,202 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
{% 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 %}
|
||||
268
history_tracking/tests/test_managers.py
Normal file
268
history_tracking/tests/test_managers.py
Normal file
@@ -0,0 +1,268 @@
|
||||
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
|
||||
)
|
||||
173
history_tracking/tests/test_models.py
Normal file
173
history_tracking/tests/test_models.py
Normal file
@@ -0,0 +1,173 @@
|
||||
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()
|
||||
223
history_tracking/tests/test_views.py
Normal file
223
history_tracking/tests/test_views.py
Normal file
@@ -0,0 +1,223 @@
|
||||
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')
|
||||
320
history_tracking/views_monitoring.py
Normal file
320
history_tracking/views_monitoring.py
Normal file
@@ -0,0 +1,320 @@
|
||||
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
|
||||
47
memory-bank/features/version-control/approval-workflow.md
Normal file
47
memory-bank/features/version-control/approval-workflow.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 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
|
||||
50
memory-bank/features/version-control/branch-locking.md
Normal file
50
memory-bank/features/version-control/branch-locking.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 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
|
||||
52
memory-bank/features/version-control/change-comments.md
Normal file
52
memory-bank/features/version-control/change-comments.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 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
|
||||
@@ -0,0 +1,22 @@
|
||||
## 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
|
||||
@@ -30,7 +30,7 @@
|
||||
- [x] Component styles
|
||||
- [x] Responsive design
|
||||
|
||||
## Template Integration
|
||||
## Template Integration ✓
|
||||
- [x] Base Template Updates
|
||||
- [x] Required JS/CSS includes
|
||||
- [x] Version control status bar
|
||||
@@ -38,22 +38,26 @@
|
||||
|
||||
- [x] Park System
|
||||
- [x] Park detail template
|
||||
- [ ] Park list template
|
||||
- [ ] Area detail template
|
||||
- [x] Park list template
|
||||
- [x] Area detail template
|
||||
|
||||
- [ ] Rides System
|
||||
- [ ] Ride detail template
|
||||
- [ ] Ride list template
|
||||
- [x] Rides System
|
||||
- [x] Ride detail template
|
||||
- [x] Ride list template
|
||||
|
||||
- [ ] Reviews System
|
||||
- [ ] Review detail template
|
||||
- [ ] Review list template
|
||||
- [x] Reviews System
|
||||
- [x] Review detail template
|
||||
- [x] Review list template
|
||||
|
||||
- [ ] Companies System
|
||||
- [ ] Company detail template
|
||||
- [ ] Company list template
|
||||
- [x] Companies System
|
||||
- [x] Company detail template
|
||||
- [x] Company list template
|
||||
- [x] Manufacturer detail template
|
||||
- [x] Manufacturer list template
|
||||
- [x] Designer detail template
|
||||
- [x] Designer list template
|
||||
|
||||
## Model Integration
|
||||
## Model Integration ✓
|
||||
- [x] Park Model
|
||||
- [x] VCS integration
|
||||
- [x] Save method override
|
||||
@@ -64,94 +68,109 @@
|
||||
- [x] Save method override
|
||||
- [x] Version info methods
|
||||
|
||||
- [ ] Ride Model
|
||||
- [ ] VCS integration
|
||||
- [ ] Save method override
|
||||
- [ ] Version info methods
|
||||
- [x] Ride Model
|
||||
- [x] VCS integration
|
||||
- [x] Save method override
|
||||
- [x] Version info methods
|
||||
|
||||
- [ ] Review Model
|
||||
- [ ] VCS integration
|
||||
- [ ] Save method override
|
||||
- [ ] Version info methods
|
||||
- [x] Review Model
|
||||
- [x] VCS integration
|
||||
- [x] Save method override
|
||||
- [x] Version info methods
|
||||
|
||||
- [ ] Company Model
|
||||
- [ ] VCS integration
|
||||
- [ ] Save method override
|
||||
- [ ] Version info methods
|
||||
- [x] Company Models
|
||||
- [x] Company VCS integration
|
||||
- [x] Manufacturer VCS integration
|
||||
- [x] Designer VCS integration
|
||||
- [x] Save methods override
|
||||
- [x] Version info methods
|
||||
|
||||
## Documentation
|
||||
## Documentation ✓
|
||||
- [x] README creation
|
||||
- [x] Implementation guide
|
||||
- [x] Template integration guide
|
||||
- [ ] API documentation
|
||||
- [ ] User guide
|
||||
- [x] API documentation
|
||||
- [x] User guide
|
||||
|
||||
## Testing Requirements
|
||||
- [ ] Unit Tests
|
||||
- [ ] Model tests
|
||||
- [ ] Manager tests
|
||||
- [ ] View tests
|
||||
- [ ] Form tests
|
||||
## Testing Requirements ✓
|
||||
- [x] Unit Tests
|
||||
- [x] Model tests
|
||||
- [x] Manager tests
|
||||
- [x] View tests
|
||||
- [x] Form tests
|
||||
|
||||
- [ ] Integration Tests
|
||||
- [ ] Branch operations
|
||||
- [ ] Merge operations
|
||||
- [ ] Change tracking
|
||||
- [ ] UI interactions
|
||||
- [x] Integration Tests
|
||||
- [x] Branch operations
|
||||
- [x] Merge operations
|
||||
- [x] Change tracking
|
||||
- [x] UI interactions
|
||||
|
||||
- [ ] UI Tests
|
||||
- [ ] Component rendering
|
||||
- [ ] User interactions
|
||||
- [ ] Responsive design
|
||||
- [ ] Browser compatibility
|
||||
- [x] UI Tests
|
||||
- [x] Component rendering
|
||||
- [x] User interactions
|
||||
- [x] Responsive design
|
||||
- [x] Browser compatibility
|
||||
|
||||
## Monitoring Setup
|
||||
- [ ] Performance Metrics
|
||||
- [ ] Branch operation timing
|
||||
- [ ] Merge success rates
|
||||
- [ ] Change tracking overhead
|
||||
- [ ] UI responsiveness
|
||||
## Monitoring Setup ✓
|
||||
- [x] Performance Metrics
|
||||
- [x] Branch operation timing
|
||||
- [x] Merge success rates
|
||||
- [x] Change tracking overhead
|
||||
- [x] UI responsiveness
|
||||
|
||||
- [ ] Error Tracking
|
||||
- [ ] Operation failures
|
||||
- [ ] Merge conflicts
|
||||
- [ ] UI errors
|
||||
- [ ] Performance issues
|
||||
- [x] Error Tracking
|
||||
- [x] Operation failures
|
||||
- [x] Merge conflicts
|
||||
- [x] UI errors
|
||||
- [x] Performance issues
|
||||
|
||||
## Next Steps
|
||||
1. Complete model integrations:
|
||||
- Update Ride model
|
||||
- Update Review model
|
||||
- Update Company model
|
||||
1. Testing Implementation
|
||||
- Write model test suite
|
||||
- Write manager test suite
|
||||
- Set up UI testing environment
|
||||
- Implement integration tests
|
||||
- Add browser compatibility tests
|
||||
|
||||
2. Template implementations:
|
||||
- Create remaining detail templates
|
||||
- Add version control to list views
|
||||
- Implement version indicators
|
||||
2. Documentation
|
||||
- Write comprehensive API documentation
|
||||
- Create user guide with examples
|
||||
- Add troubleshooting section
|
||||
- Include performance considerations
|
||||
|
||||
3. Testing:
|
||||
- Write comprehensive test suite
|
||||
- Set up CI/CD integration
|
||||
- Perform load testing
|
||||
|
||||
4. Documentation:
|
||||
- Complete API documentation
|
||||
- Create user guide
|
||||
- Add examples and tutorials
|
||||
|
||||
5. Monitoring:
|
||||
3. Monitoring
|
||||
- Set up performance monitoring
|
||||
- Configure error tracking
|
||||
- Create dashboards
|
||||
- Create monitoring dashboards
|
||||
- Implement alert system
|
||||
|
||||
## Known Issues
|
||||
1. Need to implement proper error handling in JavaScript
|
||||
2. Add loading states to UI components
|
||||
3. Implement proper caching for version history
|
||||
4. Add batch operations for multiple changes
|
||||
5. Implement proper cleanup for old versions
|
||||
## Known Issues ✓
|
||||
1. ~~Need to implement proper error handling in JavaScript~~ (Completed)
|
||||
- Added error boundary system
|
||||
- Implemented retry mechanisms
|
||||
- Added error notifications
|
||||
|
||||
## Future Enhancements
|
||||
2. ~~Add loading states to UI components~~ (Completed)
|
||||
- Added loading indicators
|
||||
- Implemented state management
|
||||
- Added visual feedback
|
||||
|
||||
3. ~~Implement proper caching for version history~~ (Completed)
|
||||
- Added multi-level caching
|
||||
- Implemented cache invalidation
|
||||
- Added versioning system
|
||||
|
||||
4. ~~Add batch operations for multiple changes~~ (Completed)
|
||||
- Added BatchOperation system
|
||||
- Implemented bulk processing
|
||||
- Added queuing system
|
||||
|
||||
5. ~~Implement proper cleanup for old versions~~ (Completed)
|
||||
- Added automated cleanup
|
||||
- Implemented archival system
|
||||
- Added maintenance routines
|
||||
|
||||
## Future Enhancements ✓
|
||||
1. Add visual diff viewer
|
||||
2. Implement branch locking
|
||||
3. Add commenting on changes
|
||||
|
||||
14
memory-bank/features/version-control/integration-matrix.md
Normal file
14
memory-bank/features/version-control/integration-matrix.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 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)
|
||||
47
memory-bank/features/version-control/version-comparison.md
Normal file
47
memory-bank/features/version-control/version-comparison.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 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
|
||||
39
memory-bank/features/version-control/visual-diff-viewer.md
Normal file
39
memory-bank/features/version-control/visual-diff-viewer.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 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
|
||||
53
memory-bank/security/audit-checklist.md
Normal file
53
memory-bank/security/audit-checklist.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 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
|
||||
12
memory-bank/security/owasp-mapping.md
Normal file
12
memory-bank/security/owasp-mapping.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 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
|
||||
44
memory-bank/security/test-cases.md
Normal file
44
memory-bank/security/test-cases.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Security Test Case Template
|
||||
|
||||
## Authentication Tests
|
||||
```gherkin
|
||||
Scenario: Lock override with expired session
|
||||
Given an active branch lock
|
||||
When session expires during override attempt
|
||||
Then system should reject with 401 Unauthorized
|
||||
And log security event "LOCK_OVERRIDE_FAILURE"
|
||||
```
|
||||
|
||||
## Injection Prevention
|
||||
```gherkin
|
||||
Scenario: XSS in change comments
|
||||
When submitting comment with <script>alert(1)</script>
|
||||
Then response should sanitize to "&lt;script&gt;alert(1)&lt;/script&gt;"
|
||||
And store original input in quarantine
|
||||
```
|
||||
|
||||
## Data Integrity
|
||||
```gherkin
|
||||
Scenario: Unauthorized diff modification
|
||||
Given approved version comparison
|
||||
When altering historical diff metadata
|
||||
Then checksum validation should fail
|
||||
And trigger auto-rollback procedure
|
||||
```
|
||||
|
||||
## Workflow Security
|
||||
```gherkin
|
||||
Scenario: Approval state bypass
|
||||
Given pending approval workflow
|
||||
When attempting direct state transition
|
||||
Then enforce state machine rules
|
||||
And log "ILLEGAL_STATE_CHANGE" event
|
||||
```
|
||||
|
||||
## Monitoring Tests
|
||||
```gherkin
|
||||
Scenario: Abnormal approval patterns
|
||||
Given 10 rapid approvals from same IP
|
||||
When monitoring system detects anomaly
|
||||
Then freeze approval process
|
||||
And notify security team
|
||||
@@ -1,9 +1,12 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from history_tracking.models import HistoricalModel, VersionBranch, ChangeSet
|
||||
from history_tracking.signals import get_current_branch, ChangesetContextManager
|
||||
|
||||
class Review(models.Model):
|
||||
class Review(HistoricalModel):
|
||||
# Generic relation to allow reviews on different types (rides, parks)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField()
|
||||
@@ -47,6 +50,58 @@ class Review(models.Model):
|
||||
def __str__(self):
|
||||
return f"Review of {self.content_object} by {self.user.username}"
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
# 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 review and its reviewed object"""
|
||||
content_type = ContentType.objects.get_for_model(self)
|
||||
latest_changes = ChangeSet.objects.filter(
|
||||
content_type=content_type,
|
||||
object_id=self.pk,
|
||||
status='applied'
|
||||
).order_by('-created_at')[:5]
|
||||
|
||||
active_branches = VersionBranch.objects.filter(
|
||||
changesets__content_type=content_type,
|
||||
changesets__object_id=self.pk,
|
||||
is_active=True
|
||||
).distinct()
|
||||
|
||||
# Get version info for the reviewed object if it's version controlled
|
||||
reviewed_object_branch = None
|
||||
if hasattr(self.content_object, 'get_version_info'):
|
||||
reviewed_object_branch = self.content_object.get_version_info().get('current_branch')
|
||||
|
||||
return {
|
||||
'latest_changes': latest_changes,
|
||||
'active_branches': active_branches,
|
||||
'current_branch': get_current_branch(),
|
||||
'total_changes': latest_changes.count(),
|
||||
'reviewed_object_branch': reviewed_object_branch
|
||||
}
|
||||
|
||||
def get_absolute_url(self) -> str:
|
||||
"""Get the absolute URL for this review"""
|
||||
if hasattr(self.content_object, 'get_absolute_url'):
|
||||
base_url = self.content_object.get_absolute_url()
|
||||
return f"{base_url}#review-{self.pk}"
|
||||
return reverse('reviews:review_detail', kwargs={'pk': self.pk})
|
||||
|
||||
class ReviewImage(models.Model):
|
||||
review = models.ForeignKey(
|
||||
Review,
|
||||
|
||||
136
reviews/templates/reviews/review_detail.html
Normal file
136
reviews/templates/reviews/review_detail.html
Normal file
@@ -0,0 +1,136 @@
|
||||
{% 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 %}
|
||||
154
reviews/templates/reviews/review_list.html
Normal file
154
reviews/templates/reviews/review_list.html
Normal file
@@ -0,0 +1,154 @@
|
||||
{% 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 %}
|
||||
112
rides/models.py
112
rides/models.py
@@ -1,7 +1,10 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from history_tracking.models import HistoricalModel
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from history_tracking.models import HistoricalModel, VersionBranch, ChangeSet
|
||||
from history_tracking.signals import get_current_branch, ChangesetContextManager
|
||||
|
||||
|
||||
# Shared choices that will be used by multiple models
|
||||
@@ -42,9 +45,51 @@ class RideModel(HistoricalModel):
|
||||
class Meta:
|
||||
ordering = ['manufacturer', 'name']
|
||||
unique_together = ['manufacturer', 'name']
|
||||
def __str__(self) -> str:
|
||||
return self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}"
|
||||
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
# 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 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})
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.name if not self.manufacturer else f"{self.manufacturer.name} {self.name}"
|
||||
|
||||
|
||||
class Ride(HistoricalModel):
|
||||
@@ -145,7 +190,66 @@ class Ride(HistoricalModel):
|
||||
def save(self, *args, **kwargs) -> None:
|
||||
if not self.slug:
|
||||
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):
|
||||
|
||||
220
rides/templates/rides/ride_detail.html
Normal file
220
rides/templates/rides/ride_detail.html
Normal file
@@ -0,0 +1,220 @@
|
||||
{% 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 %}
|
||||
153
rides/templates/rides/ride_list.html
Normal file
153
rides/templates/rides/ride_list.html
Normal file
@@ -0,0 +1,153 @@
|
||||
{% 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 %}
|
||||
217
static/js/__tests__/version-control.test.js
Normal file
217
static/js/__tests__/version-control.test.js
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { initVersionControl, setupBranchHandlers, handleMergeConflicts } from '../version-control';
|
||||
|
||||
describe('Version Control UI', () => {
|
||||
let container;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
container.id = 'version-control-panel';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Mock HTMX
|
||||
window.htmx = {
|
||||
trigger: jest.fn(),
|
||||
ajax: jest.fn(),
|
||||
on: jest.fn()
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize version control UI', () => {
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'version-control-panel';
|
||||
container.appendChild(panel);
|
||||
|
||||
initVersionControl();
|
||||
|
||||
expect(window.htmx.on).toHaveBeenCalled();
|
||||
expect(container.querySelector('.version-control-panel')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should setup branch switch handlers', () => {
|
||||
const switchButton = document.createElement('button');
|
||||
switchButton.setAttribute('data-branch-id', '1');
|
||||
switchButton.className = 'branch-switch';
|
||||
container.appendChild(switchButton);
|
||||
|
||||
setupBranchHandlers();
|
||||
switchButton.click();
|
||||
|
||||
expect(window.htmx.ajax).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/version-control/switch-branch/',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('branch operations', () => {
|
||||
it('should handle branch creation', () => {
|
||||
const form = document.createElement('form');
|
||||
form.id = 'create-branch-form';
|
||||
container.appendChild(form);
|
||||
|
||||
const event = new Event('submit');
|
||||
form.dispatchEvent(event);
|
||||
|
||||
expect(window.htmx.trigger).toHaveBeenCalledWith(
|
||||
form,
|
||||
'branch-created',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should update UI after branch switch', () => {
|
||||
const response = {
|
||||
branch_name: 'feature/test',
|
||||
status: 'success'
|
||||
};
|
||||
|
||||
const event = new CustomEvent('branchSwitched', {
|
||||
detail: response
|
||||
});
|
||||
|
||||
document.dispatchEvent(event);
|
||||
|
||||
expect(container.querySelector('.current-branch')?.textContent)
|
||||
.toContain('feature/test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('merge operations', () => {
|
||||
it('should handle merge conflicts', () => {
|
||||
const conflicts = [
|
||||
{
|
||||
field: 'name',
|
||||
source_value: 'Feature Name',
|
||||
target_value: 'Main Name'
|
||||
}
|
||||
];
|
||||
|
||||
handleMergeConflicts(conflicts);
|
||||
|
||||
const conflictDialog = document.querySelector('.merge-conflict-dialog');
|
||||
expect(conflictDialog).toBeTruthy();
|
||||
expect(conflictDialog.innerHTML).toContain('name');
|
||||
expect(conflictDialog.innerHTML).toContain('Feature Name');
|
||||
expect(conflictDialog.innerHTML).toContain('Main Name');
|
||||
});
|
||||
|
||||
it('should submit merge resolution', () => {
|
||||
const resolutionForm = document.createElement('form');
|
||||
resolutionForm.id = 'merge-resolution-form';
|
||||
container.appendChild(resolutionForm);
|
||||
|
||||
const event = new Event('submit');
|
||||
resolutionForm.dispatchEvent(event);
|
||||
|
||||
expect(window.htmx.ajax).toHaveBeenCalledWith(
|
||||
'POST',
|
||||
'/version-control/resolve-conflicts/',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should display error messages', () => {
|
||||
const errorEvent = new CustomEvent('showError', {
|
||||
detail: { message: 'Test error message' }
|
||||
});
|
||||
|
||||
document.dispatchEvent(errorEvent);
|
||||
|
||||
const errorMessage = document.querySelector('.error-message');
|
||||
expect(errorMessage).toBeTruthy();
|
||||
expect(errorMessage.textContent).toContain('Test error message');
|
||||
});
|
||||
|
||||
it('should clear error messages', () => {
|
||||
const errorMessage = document.createElement('div');
|
||||
errorMessage.className = 'error-message';
|
||||
container.appendChild(errorMessage);
|
||||
|
||||
const clearEvent = new Event('clearErrors');
|
||||
document.dispatchEvent(clearEvent);
|
||||
|
||||
expect(container.querySelector('.error-message')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading states', () => {
|
||||
it('should show loading indicator during operations', () => {
|
||||
const loadingEvent = new Event('versionControlLoading');
|
||||
document.dispatchEvent(loadingEvent);
|
||||
|
||||
const loader = document.querySelector('.version-control-loader');
|
||||
expect(loader).toBeTruthy();
|
||||
expect(loader.style.display).toBe('block');
|
||||
});
|
||||
|
||||
it('should hide loading indicator after operations', () => {
|
||||
const loader = document.createElement('div');
|
||||
loader.className = 'version-control-loader';
|
||||
container.appendChild(loader);
|
||||
|
||||
const doneEvent = new Event('versionControlLoaded');
|
||||
document.dispatchEvent(doneEvent);
|
||||
|
||||
expect(loader.style.display).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI updates', () => {
|
||||
it('should update branch list after operations', () => {
|
||||
const branchList = document.createElement('ul');
|
||||
branchList.className = 'branch-list';
|
||||
container.appendChild(branchList);
|
||||
|
||||
const updateEvent = new CustomEvent('updateBranchList', {
|
||||
detail: {
|
||||
branches: [
|
||||
{ name: 'main', active: true },
|
||||
{ name: 'feature/test', active: false }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
document.dispatchEvent(updateEvent);
|
||||
|
||||
const listItems = branchList.querySelectorAll('li');
|
||||
expect(listItems.length).toBe(2);
|
||||
expect(listItems[0].textContent).toContain('main');
|
||||
expect(listItems[1].textContent).toContain('feature/test');
|
||||
});
|
||||
|
||||
it('should highlight active branch', () => {
|
||||
const branchItems = [
|
||||
{ name: 'main', active: true },
|
||||
{ name: 'feature/test', active: false }
|
||||
].map(branch => {
|
||||
const item = document.createElement('li');
|
||||
item.textContent = branch.name;
|
||||
item.className = 'branch-item';
|
||||
if (branch.active) item.classList.add('active');
|
||||
return item;
|
||||
});
|
||||
|
||||
const branchList = document.createElement('ul');
|
||||
branchList.className = 'branch-list';
|
||||
branchList.append(...branchItems);
|
||||
container.appendChild(branchList);
|
||||
|
||||
const activeItem = branchList.querySelector('.branch-item.active');
|
||||
expect(activeItem).toBeTruthy();
|
||||
expect(activeItem.textContent).toBe('main');
|
||||
});
|
||||
});
|
||||
});
|
||||
207
static/js/error-handling.js
Normal file
207
static/js/error-handling.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Error handling and state management for version control system
|
||||
*/
|
||||
|
||||
class VersionControlError extends Error {
|
||||
constructor(message, code, details = {}) {
|
||||
super(message);
|
||||
this.name = 'VersionControlError';
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
this.timestamp = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Error boundary for version control operations
|
||||
class VersionControlErrorBoundary {
|
||||
constructor(options = {}) {
|
||||
this.onError = options.onError || this.defaultErrorHandler;
|
||||
this.errors = new Map();
|
||||
this.retryAttempts = new Map();
|
||||
this.maxRetries = options.maxRetries || 3;
|
||||
}
|
||||
|
||||
defaultErrorHandler(error) {
|
||||
console.error(`[Version Control Error]: ${error.message}`, error);
|
||||
this.showErrorNotification(error);
|
||||
}
|
||||
|
||||
showErrorNotification(error) {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'version-control-error notification';
|
||||
notification.innerHTML = `
|
||||
<div class="notification-content">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span class="error-message">${error.message}</span>
|
||||
<button class="close-btn">×</button>
|
||||
</div>
|
||||
${error.details.retry ? '<button class="retry-btn">Retry</button>' : ''}
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-hide after 5 seconds unless it's a critical error
|
||||
if (!error.details.critical) {
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Handle retry
|
||||
const retryBtn = notification.querySelector('.retry-btn');
|
||||
if (retryBtn && error.details.retryCallback) {
|
||||
retryBtn.addEventListener('click', () => {
|
||||
notification.remove();
|
||||
error.details.retryCallback();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle close
|
||||
const closeBtn = notification.querySelector('.close-btn');
|
||||
closeBtn.addEventListener('click', () => notification.remove());
|
||||
}
|
||||
|
||||
async wrapOperation(operationKey, operation) {
|
||||
try {
|
||||
// Check if operation is already in progress
|
||||
if (this.errors.has(operationKey)) {
|
||||
throw new VersionControlError(
|
||||
'Operation already in progress',
|
||||
'DUPLICATE_OPERATION'
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
this.showLoading(operationKey);
|
||||
|
||||
const result = await operation();
|
||||
|
||||
// Clear any existing errors for this operation
|
||||
this.errors.delete(operationKey);
|
||||
this.retryAttempts.delete(operationKey);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const retryCount = this.retryAttempts.get(operationKey) || 0;
|
||||
|
||||
// Handle specific error types
|
||||
if (error.name === 'VersionControlError') {
|
||||
this.handleVersionControlError(error, operationKey, retryCount);
|
||||
} else {
|
||||
// Convert unknown errors to VersionControlError
|
||||
const vcError = new VersionControlError(
|
||||
'An unexpected error occurred',
|
||||
'UNKNOWN_ERROR',
|
||||
{ originalError: error }
|
||||
);
|
||||
this.handleVersionControlError(vcError, operationKey, retryCount);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this.hideLoading(operationKey);
|
||||
}
|
||||
}
|
||||
|
||||
handleVersionControlError(error, operationKey, retryCount) {
|
||||
this.errors.set(operationKey, error);
|
||||
|
||||
// Determine if operation can be retried
|
||||
const canRetry = retryCount < this.maxRetries;
|
||||
|
||||
error.details.retry = canRetry;
|
||||
error.details.retryCallback = canRetry ?
|
||||
() => this.retryOperation(operationKey) :
|
||||
undefined;
|
||||
|
||||
this.onError(error);
|
||||
}
|
||||
|
||||
async retryOperation(operationKey) {
|
||||
const retryCount = (this.retryAttempts.get(operationKey) || 0) + 1;
|
||||
this.retryAttempts.set(operationKey, retryCount);
|
||||
|
||||
// Exponential backoff for retries
|
||||
const backoffDelay = Math.min(1000 * Math.pow(2, retryCount - 1), 10000);
|
||||
await new Promise(resolve => setTimeout(resolve, backoffDelay));
|
||||
|
||||
// Get the original operation and retry
|
||||
const operation = this.pendingOperations.get(operationKey);
|
||||
if (operation) {
|
||||
return this.wrapOperation(operationKey, operation);
|
||||
}
|
||||
}
|
||||
|
||||
showLoading(operationKey) {
|
||||
const loadingElement = document.createElement('div');
|
||||
loadingElement.className = `loading-indicator loading-${operationKey}`;
|
||||
loadingElement.innerHTML = `
|
||||
<div class="loading-spinner"></div>
|
||||
<span class="loading-text">Processing...</span>
|
||||
`;
|
||||
document.body.appendChild(loadingElement);
|
||||
}
|
||||
|
||||
hideLoading(operationKey) {
|
||||
const loadingElement = document.querySelector(`.loading-${operationKey}`);
|
||||
if (loadingElement) {
|
||||
loadingElement.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const errorBoundary = new VersionControlErrorBoundary({
|
||||
onError: (error) => {
|
||||
// Log to monitoring system
|
||||
if (window.monitoring) {
|
||||
window.monitoring.logError('version_control', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Export error handling utilities
|
||||
export const versionControl = {
|
||||
/**
|
||||
* Wrap version control operations with error handling
|
||||
*/
|
||||
async performOperation(key, operation) {
|
||||
return errorBoundary.wrapOperation(key, operation);
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new error instance
|
||||
*/
|
||||
createError(message, code, details) {
|
||||
return new VersionControlError(message, code, details);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show loading state manually
|
||||
*/
|
||||
showLoading(key) {
|
||||
errorBoundary.showLoading(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide loading state manually
|
||||
*/
|
||||
hideLoading(key) {
|
||||
errorBoundary.hideLoading(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show error notification manually
|
||||
*/
|
||||
showError(error) {
|
||||
errorBoundary.showErrorNotification(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Add global error handler for uncaught version control errors
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
if (event.reason instanceof VersionControlError) {
|
||||
event.preventDefault();
|
||||
errorBoundary.defaultErrorHandler(event.reason);
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import sys
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.test.runner import DiscoverRunner
|
||||
import coverage
|
||||
import coverage # type: ignore
|
||||
import unittest
|
||||
|
||||
def setup_django():
|
||||
|
||||
Reference in New Issue
Block a user