Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
84d684d
added automatic release svg generation
bhargav-j47 Nov 8, 2025
06511ea
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 8, 2025
2213e0e
Update generate_release_roadmap.py
bhargav-j47 Nov 8, 2025
1b18f70
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 8, 2025
0a42640
Update tools/template.svg.jinja
bhargav-j47 Nov 17, 2025
c4d691f
Merge branch 'django:main' into release-imp
bhargav-j47 Nov 17, 2025
4a49490
Update tools/generate_release_roadmap.py
bhargav-j47 Nov 18, 2025
c9599a3
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 18, 2025
6f75394
added suggested changes
bhargav-j47 Nov 18, 2025
8a06d6f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 18, 2025
7c5aa13
Update generate_release_roadmap.py
bhargav-j47 Nov 18, 2025
5e140e9
making svg more similar to png and adding fade
bhargav-j47 Nov 19, 2025
9feb2fc
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 19, 2025
90d5ba3
update generate_release_roadmap.py
bhargav-j47 Nov 19, 2025
c791ec1
improving fade
bhargav-j47 Nov 19, 2025
c9a3e88
size changes to make svg similar to old png
bhargav-j47 Nov 19, 2025
23fb4b2
Update tools/generate_release_roadmap.py
bhargav-j47 Nov 19, 2025
4500278
Update tools/generate_release_roadmap.py
bhargav-j47 Nov 19, 2025
460c01b
Merge branch 'django:main' into release-imp
bhargav-j47 Nov 19, 2025
c4c15f6
commiting latest svg
bhargav-j47 Nov 19, 2025
2479366
updated og:image:type and appereance changes to match original png
bhargav-j47 Nov 20, 2025
3a1d1b2
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 20, 2025
1c5c6c5
adding generate roadmap as management command in releases app
bhargav-j47 Nov 20, 2025
5f6f840
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 20, 2025
db1d6e7
minor visual changes
bhargav-j47 Nov 20, 2025
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
Binary file removed djangoproject/static/img/release-roadmap.png
Binary file not shown.
1,441 changes: 703 additions & 738 deletions djangoproject/static/img/release-roadmap.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion djangoproject/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
<meta property="og:image:alt" content="{% block og_image_alt %}Django logo{% endblock %}" />
<meta property="og:image:width" content="{% block og_image_width %}1200{% endblock %}" />
<meta property="og:image:height" content="{% block og_image_height %}546{% endblock %}" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:type" content="{% block og_image_type%}image/png{% endblock %}"/>
<meta property="og:url" content="{{ request.build_absolute_uri }}" />
<meta property="og:site_name" content="Django Project" />

Expand Down
6 changes: 3 additions & 3 deletions djangoproject/templates/releases/download.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@
{% block layout_class %}sidebar-right{% endblock %}

{% block og_title %}Download Django{% endblock %}
{% block og_image %}{% static "img/release-roadmap.png" %}{% endblock %}
{% block og_image %}{% static "img/release-roadmap.svg" %}{% endblock %}
Copy link
Member

Choose a reason for hiding this comment

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

The base.html template hardcodes the content type of og_image to png:

<meta property="og:image:type" content="image/png" />

We should create a new og_image_type block that defaults to png but that we override for this template to use svg.

{% block og_image_alt %}Django's release roadmap{% endblock %}
{% block og_description %}The latest official version is {{ current.version }}{% if current.is_lts %} (LTS){% endif %}{% endblock %}
{% block og_image_width %}1030{% endblock %}
{% block og_image_height %}480{% endblock %}

{% block og_image_type%} image/svg+xml{% endblock %}
{% block header %}
<p>Download</p>
{% endblock %}
Expand Down Expand Up @@ -74,7 +74,7 @@ <h2 id="supported-versions">Supported Versions</h2>
<p>See the <a href="https://docs.djangoproject.com/en/dev/internals/release-process/#supported-versions">
supported versions policy</a> for detailed guidelines about what fixes will be backported.</p>

<img src="{% static "img/release-roadmap.png" %}" class='img-release' style="max-width:100%;" alt="Django release roadmap">
<img src="{% static "img/release-roadmap.svg" %}" class='img-release' style="max-width:100%;" alt="Django release roadmap">
<hr style="margin-bottom: 20px;">

<table class='django-supported-versions'>
Expand Down
Empty file added releases/management/__init__.py
Empty file.
Empty file.
340 changes: 340 additions & 0 deletions releases/management/commands/generate_release_roadmap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,340 @@
"""
Generates an SVG roadmap of Django releases,
showing mainstream and extended support periods.

Usage:
python -m manage generate_release_roadmap.py --first-release <VERSION> --date <YYYY-MM>

Arguments:
--first-release First release number in Django versioning style, e.g.,"4.2"
--date Release date of first release in YYYY-MM format, e.g.,"2023-04"

Behavior:
- Automatically generates 8 consecutive Django releases:
X.0, X.1, X.2 (LTS), X+1.0, X+1.1, X+1.2 (LTS), X+2.0, X+2.1
- Mainstream support: 8 months per release
- Extended support:
- LTS releases (*.2) have 28 months of extended support beyond mainstream
- Non-LTS releases have 8 months of extended support beyond mainstream
- Produces an SVG at: ../djangoproject/static/img/release-roadmap.svg
"""

import datetime as dtime
from pathlib import Path

from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.management.base import BaseCommand
from jinja2 import Environment, FileSystemLoader

TEMPLATE_DIR = Path(__file__).resolve().parent

OUTPUT_FILE = (
settings.BASE_DIR / "djangoproject" / "static" / "img" / "release-roadmap.svg"
)

COLORS = {
"mainstream": "#0C4B33",
"extended": "#CBFDE9",
"grid": "#000000",
"month-grid": "#666666",
"text": "#ffffff",
"legend_text": "#000000",
"text_lts": "#0C4B33",
"bg": "none",
}

CONFIG = {
"pixels_per_year": 120,
"bar_height": 32,
"bar_v_spacing": 10,
"padding_top": 30,
"padding_bottom": 20,
"padding_left": 20,
"padding_right": 10,
"font_family": "'Segoe UI', 'Arial'",
"font_size": 18,
"font_weight": "bold",
"font_weight_lts": "600",
"font_style_lts": "italic",
"legend_box_size": 16,
"legend_padding": 50,
"text_padding_x": 10,
"year_line_width": 3,
"month_line_width": 1,
}


class Command(BaseCommand):

help = "Generate Django release roadmap SVG."

def add_arguments(self, parser):
parser.add_argument(
"--first-release", required=True, help="First release number, e.g., 4.2"
)
parser.add_argument(
"--date",
type=lambda str: dtime.datetime.strptime(str, "%Y-%m").date(),
required=True,
help="Release date in YYYY-MM format, e.g., 2023-04",
)

def handle(self, *args, **options):
render_svg(options["first_release"], options["date"])


def get_chart_timeline(data: list, config: dict):

start_year = data[0]["release_date"].year

max_end_date = max(d["extended_end"] for d in data)

end_year = max_end_date.year + 1

total_years = end_year - start_year
chart_width = total_years * config["pixels_per_year"]
svg_width = chart_width + config["padding_left"] + config["padding_right"]

return start_year, end_year, int(svg_width)


def calculate_dimensions(config: dict, num_releases: int) -> int:

chart_height = (
config["padding_top"]
+ config["padding_bottom"]
+ (num_releases * config["bar_height"])
+ ((num_releases - 1) * config["bar_v_spacing"])
)
return int(chart_height)


def date_to_x(date: dtime.date, start_year: int, config: dict) -> float:

pixels_per_year = config["pixels_per_year"]
pixels_per_block = pixels_per_year / 3.0
start_x = config["padding_left"]

year_offset = (date.year - start_year) * pixels_per_year

if 1 <= date.month <= 4:

block_num = 0
elif 5 <= date.month <= 8:

block_num = 1
else:

block_num = 2

block_x_end = year_offset + ((block_num + 1) * pixels_per_block)

return start_x + block_x_end


def generate_grids(start_year: int, end_year: int, config: dict) -> list:

grid_lines = []
pixels_per_year = config["pixels_per_year"]
pixels_per_block = pixels_per_year / 3.0

# Month labels only for the VERY FIRST set of lines
FIRST_YEAR_MONTH_LABELS = {
0: None,
1: "April",
2: "August",
3: "December",
}
for year_index, year in enumerate(range(start_year, end_year)):
year_x_start = config["padding_left"] + (year_index * pixels_per_year)

for line_index in range(4):
x = year_x_start + (line_index * pixels_per_block)
# Year label always on first line of each year
top_label = str(year) if line_index == 0 else None
# Month labels ONLY for the first year block
if year_index == 0:
bottom_label = FIRST_YEAR_MONTH_LABELS[line_index]
else:
bottom_label = None
grid_lines.append(
{
"x": x,
"width": (
config["year_line_width"]
if line_index == 0
else config["month_line_width"]
),
"top_label": top_label,
"bottom_label": bottom_label,
"line-color": (
COLORS["grid"] if line_index == 0 else COLORS["month-grid"]
),
}
)
return grid_lines


def generate_release_data(first_release: str, release_date: dtime.date) -> list:
"""
Generate 8 Django-style releases starting from a given first release.
first_release: "4.2"
first_release_ym: "2023-04"
"""
major, minor = map(int, first_release.split("."))
# Parse YYYY-MM → date

releases = []
for i in range(8):
curr_major = major + ((minor + i) // 3)
curr_minor = (minor + i) % 3
version = f"{curr_major}.{curr_minor}"
is_lts = curr_minor == 2
# Mainstream support lasts 8 months
mainstream_end = release_date + relativedelta(months=8)
# Extended support
if is_lts:
# LTS = 28 months after mainstream ends
extended_end = mainstream_end + relativedelta(months=28)
else:
# Non-LTS = 8 months after mainstream ends
extended_end = mainstream_end + relativedelta(months=8)
releases.append(
{
"name": version,
"is_lts": is_lts,
"release_date": release_date,
"mainstream_end": mainstream_end,
"extended_end": extended_end,
}
)
# Next release starts 8 months later
release_date = release_date + relativedelta(months=8)
return releases


def generate_releases(data: list, start_year: int, config: dict) -> list:

releases_processed = []
for i, release in enumerate(data):
bar_y = config["padding_top"] + (
i * (config["bar_height"] + config["bar_v_spacing"])
)
text_y_center = bar_y + (config["bar_height"] / 2) + (config["font_size"] / 3)

x_start = date_to_x(release["release_date"], start_year, config)
x_end_mainstream = date_to_x(release["mainstream_end"], start_year, config)
x_end_extended = date_to_x(release["extended_end"], start_year, config)

mainstream_bar = {
"x": x_start,
"y": bar_y,
"width": x_end_mainstream - x_start,
"height": config["bar_height"],
"fill": COLORS["mainstream"],
}

extended_bar = {
"x": x_end_mainstream,
"y": bar_y,
"width": x_end_extended - x_end_mainstream,
"height": config["bar_height"],
"fill": COLORS["extended"],
}

version_text = {
"x": x_start + config["text_padding_x"],
"y": text_y_center,
"text": release["name"],
}

lts_text = None
if release.get("is_lts", False):
lts_text = {
"x": x_end_mainstream + config["text_padding_x"],
"y": text_y_center,
"text": "LTS",
}

releases_processed.append(
{
"mainstream_bar": mainstream_bar,
"extended_bar": extended_bar,
"version_text": version_text,
"lts_text": lts_text,
}
)
return releases_processed


def generate_legend(config: dict) -> dict:

legend_y = (
config["padding_top"] + 260
) # Fixed position for legend so that it doesn't conflict with month labels

width = config["legend_box_size"] + 100
height = config["legend_box_size"] + 24

legend = {
"mainstream_box": {
"x": config["padding_left"],
"y": legend_y - config["legend_box_size"] + 2,
"size": config["legend_box_size"],
"width": width,
"height": height,
"fill": COLORS["mainstream"],
},
"mainstream_text": {
"x": config["padding_left"] + config["legend_box_size"] + 5,
"y": legend_y,
"fill": "#ffffff",
"text": ["Mainstream", "Support"],
},
"extended_box": {
"x": config["padding_left"] + width,
"y": legend_y - config["legend_box_size"] + 2,
"size": config["legend_box_size"],
"width": width,
"height": height,
"fill": COLORS["extended"],
},
"extended_text": {
"x": config["padding_left"] + config["legend_box_size"] + width + 8,
"y": legend_y,
"fill": "#000000",
"text": ["Extended", "Support"],
},
}

return legend


def render_svg(first_release: str, date: dtime.date):

data = generate_release_data(first_release, date)

start_year, end_year, svg_width = get_chart_timeline(data, CONFIG)
svg_height = calculate_dimensions(CONFIG, len(data))

grid_lines = generate_grids(start_year, end_year, CONFIG)
releases_processed = generate_releases(data, start_year, CONFIG)

legend = generate_legend(CONFIG)

env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
template = env.get_template("template.svg.jinja")

output_svg = template.render(
svg_width=svg_width,
svg_height=svg_height,
config=CONFIG,
colors=COLORS,
grid_lines=grid_lines,
releases=releases_processed,
legend=legend,
)

with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
f.write(output_svg)
Loading