Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions tournamentcontrol/competition/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,7 @@ class Meta:
"short_title",
"copy",
"draft",
"color",
"points_formula",
"bonus_points_formula",
"games_per_day",
Expand All @@ -623,6 +624,7 @@ class Meta:
}
widgets = {
"bonus_points_formula": forms.TextInput,
"color": forms.TextInput(attrs={"type": "color"}),
}

def _clean_formula(self, field_name, calculator_class):
Expand Down Expand Up @@ -669,9 +671,13 @@ class Meta:
"scale_group_points",
"carry_ladder",
"keep_mvp",
"color",
"slug",
"slug_locked",
)
widgets = {
"color": forms.TextInput(attrs={"type": "color"}),
}


class StageGroupForm(SuperUserSlugMixin, ModelForm):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Generated migration to add color fields to Division and Stage models

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("competition", "0056_remove_ranking_tables_and_fields"),
]

operations = [
migrations.AddField(
model_name="division",
name="color",
field=models.CharField(
blank=True,
default="",
help_text="Color for division in the visual scheduler. Affects the left border of match cards and division headers.",
max_length=7,
verbose_name="Color",
),
),
migrations.AddField(
model_name="stage",
name="color",
field=models.CharField(
blank=True,
default="",
help_text="Background color for matches in the visual scheduler. Used to highlight matches of increased importance.",
max_length=7,
verbose_name="Background Color",
),
),
]
50 changes: 50 additions & 0 deletions tournamentcontrol/competition/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,17 @@ class Division(
),
)

color = models.CharField(
max_length=7,
blank=True,
default="",
verbose_name=_("Color"),
help_text=_(
"Color for division in the visual scheduler. "
"Affects the left border of match cards and division headers."
),
)

# This is an advanced feature, we would not wish to surface it under
# normal circumstances, but the theory is that we can use the report URL
# to construct the minimum data for a division.
Expand All @@ -796,6 +807,25 @@ def _get_admin_namespace(self):
def _get_url_args(self):
return (self.season.competition_id, self.season_id, self.pk)

def get_color(self):
"""
Get the division color, falling back to a default based on order.
"""
if self.color:
return self.color
# Default colors based on division order (matching CSS)
default_colors = {
1: "#e74c3c", # Red
2: "#3498db", # Blue
3: "#2ecc71", # Green
4: "#f39c12", # Orange
5: "#9b59b6", # Purple
6: "#1abc9c", # Teal
7: "#e67e22", # Dark Orange
8: "#34495e", # Dark Gray
}
return default_colors.get(self.order, "#6c757d") # Gray as ultimate fallback
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot let's make the colour mandatory and use a data migration for the historical cases instead.

On creation of a division, the colour widget default value should be randomised.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit c562ab9. Division color is now mandatory with generate_random_color() as the default. A data migration (0058) populates historical records using order-based colors. The color picker widget will show a random bright color on division creation.


@property
def matches(self):
return Match.objects.filter(stage__division=self).select_related(
Expand Down Expand Up @@ -1116,6 +1146,17 @@ class Stage(AdminUrlMixin, OrderedSitemapNode):
),
)

color = models.CharField(
max_length=7,
blank=True,
default="",
verbose_name=_("Background Color"),
help_text=_(
"Background color for matches in the visual scheduler. "
"Used to highlight matches of increased importance."
),
)

matches_needing_printing = ManyToManyField(
"Match", blank=True, related_name="to_be_printed"
)
Expand All @@ -1142,6 +1183,15 @@ def _get_url_args(self):
def _get_url_names(self):
return super()._get_url_names() + ["build", "undo", "progress"]

def get_color(self):
"""
Get the stage background color, falling back to a light green default.
"""
if self.color:
return self.color
# Default to light green (matching current CSS default for scheduled items)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot this comment doesn't date well because the current value isn't a thing anymore after the change.

We can just make this the default value (make it a db_default too) so historical values are populated.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit c562ab9. Stage color now uses db_default="#e8f5e8" and the comment has been updated to remove the temporal reference. Historical values are automatically populated by the database default.

return "#e8f5e8"

def __str__(self):
return self.title

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ body {
}

.match-item.scheduled {
/* Background color will be set via inline styles from Stage.get_color() */
/* Fallback to light green if no color is set */
background: #e8f5e8;
border-color: #4caf50;
}
Expand All @@ -197,36 +199,38 @@ body {
border-color: #ff9800;
}

/* Division-specific styling - customize colors as needed */
.match-item.division-1 {
/* Division-specific styling - fallback colors if no custom color is set */
/* These are now applied via inline styles from Division.get_color() */
/* Keeping as low-priority fallback only */
.match-item.division-1:not([style*="border-left"]) {
border-left: 4px solid #e74c3c; /* Red accent */
}

.match-item.division-2 {
.match-item.division-2:not([style*="border-left"]) {
border-left: 4px solid #3498db; /* Blue accent */
}

.match-item.division-3 {
.match-item.division-3:not([style*="border-left"]) {
border-left: 4px solid #2ecc71; /* Green accent */
}

.match-item.division-4 {
.match-item.division-4:not([style*="border-left"]) {
border-left: 4px solid #f39c12; /* Orange accent */
}

.match-item.division-5 {
.match-item.division-5:not([style*="border-left"]) {
border-left: 4px solid #9b59b6; /* Purple accent */
}

.match-item.division-6 {
.match-item.division-6:not([style*="border-left"]) {
border-left: 4px solid #1abc9c; /* Teal accent */
}

.match-item.division-7 {
.match-item.division-7:not([style*="border-left"]) {
border-left: 4px solid #e67e22; /* Dark orange accent */
}

.match-item.division-8 {
.match-item.division-8:not([style*="border-left"]) {
border-left: 4px solid #34495e; /* Dark gray accent */
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ <h1>{% trans "Visual Schedule" %} - {{ date|date }} - {{ season.title }}</h1>
<!-- Division/Stage hierarchy -->
{% for division, stages in matches_hierarchy.items %}
<div class="division-group">
<div class="division-header" @click="toggleSection('div_{{ division.id }}')">
<div class="division-header" @click="toggleSection('div_{{ division.id }}')" style="{% if division.get_color %}background-color: {{ division.get_color }};{% endif %}">
<span x-show="!collapsedSections.div_{{ division.id }}">▼</span>
<span x-show="collapsedSections.div_{{ division.id }}">▶</span>
{{ division.title }}
Expand All @@ -93,6 +93,7 @@ <h1>{% trans "Visual Schedule" %} - {{ date|date }} - {{ season.title }}</h1>
<div
x-show="!matches[{{ match.id }}]?.scheduled"
class="match-item unscheduled division-{{ match.stage.division.order }}"
style="border-left: 4px solid {{ match.stage.division.get_color }};"
draggable="true"
@dragstart="startDrag(getMatch({{ match.id }}),event)"
@dragend="endDrag($event)"
Expand Down Expand Up @@ -157,6 +158,7 @@ <h1>{% trans "Visual Schedule" %} - {{ date|date }} - {{ season.title }}</h1>
<template x-for="match in getMatchesAt(place.id, timeslot)" :key="match.id">
<div
:class="'match-item scheduled division-' + match.division_order"
:style="'background-color: ' + match.stage_color + '; border-left: 4px solid ' + match.division_color + ';'"
draggable="true"
@dragstart="startDrag(match,event)"
@dragend="endDrag($event)"
Expand Down Expand Up @@ -223,7 +225,9 @@ <h1>{% trans "Visual Schedule" %} - {{ date|date }} - {{ season.title }}</h1>
round: {{ form.instance.round|default_if_none:"null" }},
is_final: {{ form.instance.is_final|yesno:"true,false" }},
division_order: {{ form.instance.stage.division.order }},
division_title: '{{ form.instance.stage.division.abbreviation|default:form.instance.stage.division.title|escapejs }}'
division_title: '{{ form.instance.stage.division.abbreviation|default:form.instance.stage.division.title|escapejs }}',
division_color: '{{ form.instance.stage.division.get_color }}',
stage_color: '{{ form.instance.stage.get_color }}'
};
{% endfor %}

Expand Down
133 changes: 133 additions & 0 deletions tournamentcontrol/competition/tests/test_color_fields.py
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot rethink the testing based on the other comments in this review.

There should also be some tests of the actual UI with the framework tests.

The Division and Stage factories probably need an update too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit c562ab9. Tests completely rethought:

  • Added ColorGenerationTests for the random color function
  • Updated Division/Stage tests to reflect mandatory fields
  • Fixed test_add_division to include color in POST data
  • Added E2E tests (test_visual_scheduler_colors.py) with 3 browser tests verifying division headers, match borders, and stage backgrounds
  • Updated DivisionFactory and StageFactory with color generation

All tests passing: 15 unit, 69 admin, 3 E2E.

Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
from test_plus import TestCase

from tournamentcontrol.competition.forms import DivisionForm, StageForm
from tournamentcontrol.competition.tests import factories


class DivisionColorTests(TestCase):
"""Test cases for Division color field and get_color method."""

@classmethod
def setUpTestData(cls):
cls.season = factories.SeasonFactory.create()

def test_division_color_field_default(self):
"""Test that division color field defaults to empty string."""
division = factories.DivisionFactory.create(season=self.season)
self.assertEqual(division.color, "")

def test_division_color_field_accepts_hex_value(self):
"""Test that division color field accepts a hex color value."""
division = factories.DivisionFactory.create(
season=self.season, color="#ff5733"
)
self.assertEqual(division.color, "#ff5733")

def test_get_color_returns_custom_color(self):
"""Test that get_color returns custom color when set."""
division = factories.DivisionFactory.create(
season=self.season, color="#123456"
)
self.assertEqual(division.get_color(), "#123456")

def test_get_color_returns_default_for_order_1(self):
"""Test that get_color returns default red for division order 1."""
division = factories.DivisionFactory.create(season=self.season, order=1)
self.assertEqual(division.get_color(), "#e74c3c")

def test_get_color_returns_default_for_order_2(self):
"""Test that get_color returns default blue for division order 2."""
division = factories.DivisionFactory.create(season=self.season, order=2)
self.assertEqual(division.get_color(), "#3498db")

def test_get_color_returns_default_for_order_3(self):
"""Test that get_color returns default green for division order 3."""
division = factories.DivisionFactory.create(season=self.season, order=3)
self.assertEqual(division.get_color(), "#2ecc71")

def test_get_color_returns_fallback_for_unknown_order(self):
"""Test that get_color returns gray fallback for orders > 8."""
division = factories.DivisionFactory.create(season=self.season, order=10)
self.assertEqual(division.get_color(), "#6c757d")

def test_division_form_includes_color_field(self):
"""Test that DivisionForm includes the color field."""
division = factories.DivisionFactory.create(season=self.season)
form = DivisionForm(instance=division)
self.assertIn("color", form.fields)

def test_division_form_color_widget_type(self):
"""Test that DivisionForm uses color input widget."""
division = factories.DivisionFactory.create(season=self.season)
form = DivisionForm(instance=division)
widget = form.fields["color"].widget
self.assertEqual(widget.input_type, "color")

def test_division_form_saves_color(self):
"""Test that DivisionForm correctly saves color field."""
division = factories.DivisionFactory.create(season=self.season, color="")
# Verify color is initially empty
self.assertEqual(division.color, "")

# Update the color
division.color = "#abcdef"
division.save()
division.refresh_from_db()

# Verify color was saved
self.assertEqual(division.color, "#abcdef")


class StageColorTests(TestCase):
"""Test cases for Stage color field and get_color method."""

@classmethod
def setUpTestData(cls):
cls.division = factories.DivisionFactory.create()

def test_stage_color_field_default(self):
"""Test that stage color field defaults to empty string."""
stage = factories.StageFactory.create(division=self.division)
self.assertEqual(stage.color, "")

def test_stage_color_field_accepts_hex_value(self):
"""Test that stage color field accepts a hex color value."""
stage = factories.StageFactory.create(division=self.division, color="#ff5733")
self.assertEqual(stage.color, "#ff5733")

def test_get_color_returns_custom_color(self):
"""Test that get_color returns custom color when set."""
stage = factories.StageFactory.create(division=self.division, color="#fedcba")
self.assertEqual(stage.get_color(), "#fedcba")

def test_get_color_returns_default_when_empty(self):
"""Test that get_color returns default light green when no color is set."""
stage = factories.StageFactory.create(division=self.division)
self.assertEqual(stage.get_color(), "#e8f5e8")

def test_stage_form_includes_color_field(self):
"""Test that StageForm includes the color field."""
stage = factories.StageFactory.create(division=self.division)
form = StageForm(instance=stage)
self.assertIn("color", form.fields)

def test_stage_form_color_widget_type(self):
"""Test that StageForm uses color input widget."""
stage = factories.StageFactory.create(division=self.division)
form = StageForm(instance=stage)
widget = form.fields["color"].widget
self.assertEqual(widget.input_type, "color")

def test_stage_form_saves_color(self):
"""Test that StageForm correctly saves color field."""
stage = factories.StageFactory.create(division=self.division, color="")
# Verify color is initially empty
self.assertEqual(stage.color, "")

# Update the color
stage.color = "#123abc"
stage.save()
stage.refresh_from_db()

# Verify color was saved
self.assertEqual(stage.color, "#123abc")