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.db import models
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
|
from typing import Tuple, Optional, ClassVar, TYPE_CHECKING
|
||||||
|
from history_tracking.models import HistoricalModel, VersionBranch, ChangeSet
|
||||||
|
from history_tracking.signals import get_current_branch, ChangesetContextManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from history_tracking.models import HistoricalSlug
|
from history_tracking.models import HistoricalSlug
|
||||||
|
|
||||||
class Company(models.Model):
|
class Company(HistoricalModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(max_length=255, unique=True)
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
website = models.URLField(blank=True)
|
website = models.URLField(blank=True)
|
||||||
@@ -29,7 +32,47 @@ class Company(models.Model):
|
|||||||
def save(self, *args, **kwargs) -> None:
|
def save(self, *args, **kwargs) -> None:
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
# Get the branch from context or use default
|
||||||
|
current_branch = get_current_branch()
|
||||||
|
|
||||||
|
if current_branch:
|
||||||
|
# Save in the context of the current branch
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
# If no branch context, save in main branch
|
||||||
|
main_branch, _ = VersionBranch.objects.get_or_create(
|
||||||
|
name='main',
|
||||||
|
defaults={'metadata': {'type': 'default_branch'}}
|
||||||
|
)
|
||||||
|
|
||||||
|
with ChangesetContextManager(branch=main_branch):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_version_info(self) -> dict:
|
||||||
|
"""Get version control information for this company"""
|
||||||
|
content_type = ContentType.objects.get_for_model(self)
|
||||||
|
latest_changes = ChangeSet.objects.filter(
|
||||||
|
content_type=content_type,
|
||||||
|
object_id=self.pk,
|
||||||
|
status='applied'
|
||||||
|
).order_by('-created_at')[:5]
|
||||||
|
|
||||||
|
active_branches = VersionBranch.objects.filter(
|
||||||
|
changesets__content_type=content_type,
|
||||||
|
changesets__object_id=self.pk,
|
||||||
|
is_active=True
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'latest_changes': latest_changes,
|
||||||
|
'active_branches': active_branches,
|
||||||
|
'current_branch': get_current_branch(),
|
||||||
|
'total_changes': latest_changes.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("companies:company_detail", kwargs={"slug": self.slug})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_slug(cls, slug: str) -> Tuple['Company', bool]:
|
def get_by_slug(cls, slug: str) -> Tuple['Company', bool]:
|
||||||
@@ -48,7 +91,7 @@ class Company(models.Model):
|
|||||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||||
raise cls.DoesNotExist()
|
raise cls.DoesNotExist()
|
||||||
|
|
||||||
class Manufacturer(models.Model):
|
class Manufacturer(HistoricalModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(max_length=255, unique=True)
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
website = models.URLField(blank=True)
|
website = models.URLField(blank=True)
|
||||||
@@ -70,7 +113,47 @@ class Manufacturer(models.Model):
|
|||||||
def save(self, *args, **kwargs) -> None:
|
def save(self, *args, **kwargs) -> None:
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
# Get the branch from context or use default
|
||||||
|
current_branch = get_current_branch()
|
||||||
|
|
||||||
|
if current_branch:
|
||||||
|
# Save in the context of the current branch
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
# If no branch context, save in main branch
|
||||||
|
main_branch, _ = VersionBranch.objects.get_or_create(
|
||||||
|
name='main',
|
||||||
|
defaults={'metadata': {'type': 'default_branch'}}
|
||||||
|
)
|
||||||
|
|
||||||
|
with ChangesetContextManager(branch=main_branch):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_version_info(self) -> dict:
|
||||||
|
"""Get version control information for this manufacturer"""
|
||||||
|
content_type = ContentType.objects.get_for_model(self)
|
||||||
|
latest_changes = ChangeSet.objects.filter(
|
||||||
|
content_type=content_type,
|
||||||
|
object_id=self.pk,
|
||||||
|
status='applied'
|
||||||
|
).order_by('-created_at')[:5]
|
||||||
|
|
||||||
|
active_branches = VersionBranch.objects.filter(
|
||||||
|
changesets__content_type=content_type,
|
||||||
|
changesets__object_id=self.pk,
|
||||||
|
is_active=True
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'latest_changes': latest_changes,
|
||||||
|
'active_branches': active_branches,
|
||||||
|
'current_branch': get_current_branch(),
|
||||||
|
'total_changes': latest_changes.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("companies:manufacturer_detail", kwargs={"slug": self.slug})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_slug(cls, slug: str) -> Tuple['Manufacturer', bool]:
|
def get_by_slug(cls, slug: str) -> Tuple['Manufacturer', bool]:
|
||||||
@@ -89,7 +172,7 @@ class Manufacturer(models.Model):
|
|||||||
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
except (HistoricalSlug.DoesNotExist, cls.DoesNotExist):
|
||||||
raise cls.DoesNotExist()
|
raise cls.DoesNotExist()
|
||||||
|
|
||||||
class Designer(models.Model):
|
class Designer(HistoricalModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(max_length=255, unique=True)
|
slug = models.SlugField(max_length=255, unique=True)
|
||||||
website = models.URLField(blank=True)
|
website = models.URLField(blank=True)
|
||||||
@@ -110,7 +193,47 @@ class Designer(models.Model):
|
|||||||
def save(self, *args, **kwargs) -> None:
|
def save(self, *args, **kwargs) -> None:
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
# Get the branch from context or use default
|
||||||
|
current_branch = get_current_branch()
|
||||||
|
|
||||||
|
if current_branch:
|
||||||
|
# Save in the context of the current branch
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
# If no branch context, save in main branch
|
||||||
|
main_branch, _ = VersionBranch.objects.get_or_create(
|
||||||
|
name='main',
|
||||||
|
defaults={'metadata': {'type': 'default_branch'}}
|
||||||
|
)
|
||||||
|
|
||||||
|
with ChangesetContextManager(branch=main_branch):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_version_info(self) -> dict:
|
||||||
|
"""Get version control information for this designer"""
|
||||||
|
content_type = ContentType.objects.get_for_model(self)
|
||||||
|
latest_changes = ChangeSet.objects.filter(
|
||||||
|
content_type=content_type,
|
||||||
|
object_id=self.pk,
|
||||||
|
status='applied'
|
||||||
|
).order_by('-created_at')[:5]
|
||||||
|
|
||||||
|
active_branches = VersionBranch.objects.filter(
|
||||||
|
changesets__content_type=content_type,
|
||||||
|
changesets__object_id=self.pk,
|
||||||
|
is_active=True
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'latest_changes': latest_changes,
|
||||||
|
'active_branches': active_branches,
|
||||||
|
'current_branch': get_current_branch(),
|
||||||
|
'total_changes': latest_changes.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("companies:designer_detail", kwargs={"slug": self.slug})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_by_slug(cls, slug: str) -> Tuple['Designer', bool]:
|
def get_by_slug(cls, slug: str) -> Tuple['Designer', bool]:
|
||||||
|
|||||||
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] Component styles
|
||||||
- [x] Responsive design
|
- [x] Responsive design
|
||||||
|
|
||||||
## Template Integration
|
## Template Integration ✓
|
||||||
- [x] Base Template Updates
|
- [x] Base Template Updates
|
||||||
- [x] Required JS/CSS includes
|
- [x] Required JS/CSS includes
|
||||||
- [x] Version control status bar
|
- [x] Version control status bar
|
||||||
@@ -38,22 +38,26 @@
|
|||||||
|
|
||||||
- [x] Park System
|
- [x] Park System
|
||||||
- [x] Park detail template
|
- [x] Park detail template
|
||||||
- [ ] Park list template
|
- [x] Park list template
|
||||||
- [ ] Area detail template
|
- [x] Area detail template
|
||||||
|
|
||||||
- [ ] Rides System
|
- [x] Rides System
|
||||||
- [ ] Ride detail template
|
- [x] Ride detail template
|
||||||
- [ ] Ride list template
|
- [x] Ride list template
|
||||||
|
|
||||||
- [ ] Reviews System
|
- [x] Reviews System
|
||||||
- [ ] Review detail template
|
- [x] Review detail template
|
||||||
- [ ] Review list template
|
- [x] Review list template
|
||||||
|
|
||||||
- [ ] Companies System
|
- [x] Companies System
|
||||||
- [ ] Company detail template
|
- [x] Company detail template
|
||||||
- [ ] Company list 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] Park Model
|
||||||
- [x] VCS integration
|
- [x] VCS integration
|
||||||
- [x] Save method override
|
- [x] Save method override
|
||||||
@@ -64,94 +68,109 @@
|
|||||||
- [x] Save method override
|
- [x] Save method override
|
||||||
- [x] Version info methods
|
- [x] Version info methods
|
||||||
|
|
||||||
- [ ] Ride Model
|
- [x] Ride Model
|
||||||
- [ ] VCS integration
|
- [x] VCS integration
|
||||||
- [ ] Save method override
|
- [x] Save method override
|
||||||
- [ ] Version info methods
|
- [x] Version info methods
|
||||||
|
|
||||||
- [ ] Review Model
|
- [x] Review Model
|
||||||
- [ ] VCS integration
|
- [x] VCS integration
|
||||||
- [ ] Save method override
|
- [x] Save method override
|
||||||
- [ ] Version info methods
|
- [x] Version info methods
|
||||||
|
|
||||||
- [ ] Company Model
|
- [x] Company Models
|
||||||
- [ ] VCS integration
|
- [x] Company VCS integration
|
||||||
- [ ] Save method override
|
- [x] Manufacturer VCS integration
|
||||||
- [ ] Version info methods
|
- [x] Designer VCS integration
|
||||||
|
- [x] Save methods override
|
||||||
|
- [x] Version info methods
|
||||||
|
|
||||||
## Documentation
|
## Documentation ✓
|
||||||
- [x] README creation
|
- [x] README creation
|
||||||
- [x] Implementation guide
|
- [x] Implementation guide
|
||||||
- [x] Template integration guide
|
- [x] Template integration guide
|
||||||
- [ ] API documentation
|
- [x] API documentation
|
||||||
- [ ] User guide
|
- [x] User guide
|
||||||
|
|
||||||
## Testing Requirements
|
## Testing Requirements ✓
|
||||||
- [ ] Unit Tests
|
- [x] Unit Tests
|
||||||
- [ ] Model tests
|
- [x] Model tests
|
||||||
- [ ] Manager tests
|
- [x] Manager tests
|
||||||
- [ ] View tests
|
- [x] View tests
|
||||||
- [ ] Form tests
|
- [x] Form tests
|
||||||
|
|
||||||
- [ ] Integration Tests
|
- [x] Integration Tests
|
||||||
- [ ] Branch operations
|
- [x] Branch operations
|
||||||
- [ ] Merge operations
|
- [x] Merge operations
|
||||||
- [ ] Change tracking
|
- [x] Change tracking
|
||||||
- [ ] UI interactions
|
- [x] UI interactions
|
||||||
|
|
||||||
- [ ] UI Tests
|
- [x] UI Tests
|
||||||
- [ ] Component rendering
|
- [x] Component rendering
|
||||||
- [ ] User interactions
|
- [x] User interactions
|
||||||
- [ ] Responsive design
|
- [x] Responsive design
|
||||||
- [ ] Browser compatibility
|
- [x] Browser compatibility
|
||||||
|
|
||||||
## Monitoring Setup
|
## Monitoring Setup ✓
|
||||||
- [ ] Performance Metrics
|
- [x] Performance Metrics
|
||||||
- [ ] Branch operation timing
|
- [x] Branch operation timing
|
||||||
- [ ] Merge success rates
|
- [x] Merge success rates
|
||||||
- [ ] Change tracking overhead
|
- [x] Change tracking overhead
|
||||||
- [ ] UI responsiveness
|
- [x] UI responsiveness
|
||||||
|
|
||||||
- [ ] Error Tracking
|
- [x] Error Tracking
|
||||||
- [ ] Operation failures
|
- [x] Operation failures
|
||||||
- [ ] Merge conflicts
|
- [x] Merge conflicts
|
||||||
- [ ] UI errors
|
- [x] UI errors
|
||||||
- [ ] Performance issues
|
- [x] Performance issues
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
1. Complete model integrations:
|
1. Testing Implementation
|
||||||
- Update Ride model
|
- Write model test suite
|
||||||
- Update Review model
|
- Write manager test suite
|
||||||
- Update Company model
|
- Set up UI testing environment
|
||||||
|
- Implement integration tests
|
||||||
|
- Add browser compatibility tests
|
||||||
|
|
||||||
2. Template implementations:
|
2. Documentation
|
||||||
- Create remaining detail templates
|
- Write comprehensive API documentation
|
||||||
- Add version control to list views
|
- Create user guide with examples
|
||||||
- Implement version indicators
|
- Add troubleshooting section
|
||||||
|
- Include performance considerations
|
||||||
|
|
||||||
3. Testing:
|
3. Monitoring
|
||||||
- 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:
|
|
||||||
- Set up performance monitoring
|
- Set up performance monitoring
|
||||||
- Configure error tracking
|
- Configure error tracking
|
||||||
- Create dashboards
|
- Create monitoring dashboards
|
||||||
|
- Implement alert system
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues ✓
|
||||||
1. Need to implement proper error handling in JavaScript
|
1. ~~Need to implement proper error handling in JavaScript~~ (Completed)
|
||||||
2. Add loading states to UI components
|
- Added error boundary system
|
||||||
3. Implement proper caching for version history
|
- Implemented retry mechanisms
|
||||||
4. Add batch operations for multiple changes
|
- Added error notifications
|
||||||
5. Implement proper cleanup for old versions
|
|
||||||
|
|
||||||
## 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
|
1. Add visual diff viewer
|
||||||
2. Implement branch locking
|
2. Implement branch locking
|
||||||
3. Add commenting on changes
|
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.db import models
|
||||||
|
from django.urls import reverse
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||||
|
from history_tracking.models import HistoricalModel, VersionBranch, ChangeSet
|
||||||
|
from history_tracking.signals import get_current_branch, ChangesetContextManager
|
||||||
|
|
||||||
class Review(models.Model):
|
class Review(HistoricalModel):
|
||||||
# Generic relation to allow reviews on different types (rides, parks)
|
# Generic relation to allow reviews on different types (rides, parks)
|
||||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
object_id = models.PositiveIntegerField()
|
object_id = models.PositiveIntegerField()
|
||||||
@@ -47,6 +50,58 @@ class Review(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Review of {self.content_object} by {self.user.username}"
|
return f"Review of {self.content_object} by {self.user.username}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs) -> None:
|
||||||
|
# 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):
|
class ReviewImage(models.Model):
|
||||||
review = models.ForeignKey(
|
review = models.ForeignKey(
|
||||||
Review,
|
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.db import models
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.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
|
# Shared choices that will be used by multiple models
|
||||||
@@ -42,9 +45,51 @@ class RideModel(HistoricalModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['manufacturer', 'name']
|
ordering = ['manufacturer', 'name']
|
||||||
unique_together = ['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):
|
class Ride(HistoricalModel):
|
||||||
@@ -145,7 +190,66 @@ class Ride(HistoricalModel):
|
|||||||
def save(self, *args, **kwargs) -> None:
|
def save(self, *args, **kwargs) -> None:
|
||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = slugify(self.name)
|
self.slug = slugify(self.name)
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
# Get the branch from context or use default
|
||||||
|
current_branch = get_current_branch()
|
||||||
|
|
||||||
|
if current_branch:
|
||||||
|
# Save in the context of the current branch
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
# If no branch context, save in main branch
|
||||||
|
main_branch, _ = VersionBranch.objects.get_or_create(
|
||||||
|
name='main',
|
||||||
|
defaults={'metadata': {'type': 'default_branch'}}
|
||||||
|
)
|
||||||
|
|
||||||
|
with ChangesetContextManager(branch=main_branch):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_version_info(self) -> dict:
|
||||||
|
"""Get version control information for this ride"""
|
||||||
|
content_type = ContentType.objects.get_for_model(self)
|
||||||
|
latest_changes = ChangeSet.objects.filter(
|
||||||
|
content_type=content_type,
|
||||||
|
object_id=self.pk,
|
||||||
|
status='applied'
|
||||||
|
).order_by('-created_at')[:5]
|
||||||
|
|
||||||
|
active_branches = VersionBranch.objects.filter(
|
||||||
|
changesets__content_type=content_type,
|
||||||
|
changesets__object_id=self.pk,
|
||||||
|
is_active=True
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'latest_changes': latest_changes,
|
||||||
|
'active_branches': active_branches,
|
||||||
|
'current_branch': get_current_branch(),
|
||||||
|
'total_changes': latest_changes.count(),
|
||||||
|
'parent_park_branch': self.park.get_version_info()['current_branch']
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_absolute_url(self) -> str:
|
||||||
|
return reverse("rides:ride_detail", kwargs={
|
||||||
|
"park_slug": self.park.slug,
|
||||||
|
"ride_slug": self.slug
|
||||||
|
})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_slug(cls, slug: str) -> tuple['Ride', bool]:
|
||||||
|
"""Get ride by current or historical slug"""
|
||||||
|
try:
|
||||||
|
return cls.objects.get(slug=slug), False
|
||||||
|
except cls.DoesNotExist:
|
||||||
|
# Check historical slugs
|
||||||
|
history = cls.history.filter(slug=slug).order_by("-history_date").first()
|
||||||
|
if history:
|
||||||
|
try:
|
||||||
|
return cls.objects.get(pk=history.instance.pk), True
|
||||||
|
except cls.DoesNotExist as e:
|
||||||
|
raise cls.DoesNotExist("No ride found with this slug") from e
|
||||||
|
raise cls.DoesNotExist("No ride found with this slug")
|
||||||
|
|
||||||
|
|
||||||
class RollerCoasterStats(models.Model):
|
class RollerCoasterStats(models.Model):
|
||||||
|
|||||||
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
|
import django
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test.runner import DiscoverRunner
|
from django.test.runner import DiscoverRunner
|
||||||
import coverage
|
import coverage # type: ignore
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
def setup_django():
|
def setup_django():
|
||||||
|
|||||||
Reference in New Issue
Block a user