Skip to content

Commit 75b4703

Browse files
bhargav-j47pre-commit-ci[bot]sarahboycebmispelon
authored
Added a management command to generate roadmap SVG
--------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Sarah Boyce <42296566+sarahboyce@users.noreply.github.com> Co-authored-by: Baptiste Mispelon <bmispelon@gmail.com>
1 parent 2df63dd commit 75b4703

File tree

9 files changed

+1213
-742
lines changed

9 files changed

+1213
-742
lines changed
-39 KB
Binary file not shown.

djangoproject/static/img/release-roadmap.svg

Lines changed: 703 additions & 738 deletions
Loading

djangoproject/templates/base.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
<meta property="og:image:alt" content="{% block og_image_alt %}Django logo{% endblock %}" />
2929
<meta property="og:image:width" content="{% block og_image_width %}1200{% endblock %}" />
3030
<meta property="og:image:height" content="{% block og_image_height %}546{% endblock %}" />
31-
<meta property="og:image:type" content="image/png" />
31+
<meta property="og:image:type" content="{% block og_image_type%}image/png{% endblock %}"/>
3232
<meta property="og:url" content="{{ request.build_absolute_uri }}" />
3333
<meta property="og:site_name" content="Django Project" />
3434

djangoproject/templates/releases/download.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
{% block layout_class %}sidebar-right{% endblock %}
77

88
{% block og_title %}Download Django{% endblock %}
9-
{% block og_image %}{% static "img/release-roadmap.png" %}{% endblock %}
9+
{% block og_image %}{% static "img/release-roadmap.svg" %}{% endblock %}
1010
{% block og_image_alt %}Django's release roadmap{% endblock %}
1111
{% block og_description %}The latest official version is {{ current.version }}{% if current.is_lts %} (LTS){% endif %}{% endblock %}
1212
{% block og_image_width %}1030{% endblock %}
1313
{% block og_image_height %}480{% endblock %}
14-
14+
{% block og_image_type %}image/svg+xml{% endblock %}
1515
{% block header %}
1616
<p>Download</p>
1717
{% endblock %}
@@ -74,7 +74,7 @@ <h2 id="supported-versions">Supported Versions</h2>
7474
<p>See the <a href="https://docs.djangoproject.com/en/dev/internals/release-process/#supported-versions">
7575
supported versions policy</a> for detailed guidelines about what fixes will be backported.</p>
7676

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

8080
<table class='django-supported-versions'>

releases/management/__init__.py

Whitespace-only changes.

releases/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
"""
2+
Generates an SVG roadmap of Django releases,
3+
showing mainstream and extended support periods.
4+
5+
Usage:
6+
python -m manage generate_release_roadmap.py --first-release <VERSION> --date <YYYY-MM>
7+
8+
Arguments:
9+
--first-release First release number in Django versioning style, e.g.,"4.2"
10+
--date Release date of first release in YYYY-MM format, e.g.,"2023-04"
11+
12+
Behavior:
13+
- Automatically generates 8 consecutive Django releases:
14+
X.0, X.1, X.2 (LTS), X+1.0, X+1.1, X+1.2 (LTS), X+2.0, X+2.1
15+
- Mainstream support: 8 months per release
16+
- Extended support:
17+
- LTS releases (*.2) have 28 months of extended support beyond mainstream
18+
- Non-LTS releases have 8 months of extended support beyond mainstream
19+
- Produces an SVG at: ../djangoproject/static/img/release-roadmap.svg
20+
"""
21+
22+
import datetime as dtime
23+
from pathlib import Path
24+
25+
from dateutil.relativedelta import relativedelta
26+
from django.conf import settings
27+
from django.core.management.base import BaseCommand
28+
from jinja2 import Environment, FileSystemLoader
29+
30+
TEMPLATE_DIR = Path(__file__).parent.resolve()
31+
32+
OUTPUT_FILE = (
33+
settings.BASE_DIR / "djangoproject" / "static" / "img" / "release-roadmap.svg"
34+
)
35+
36+
COLORS = {
37+
"mainstream": "#0C4B33",
38+
"extended": "#CBFDE9",
39+
"grid": "#000000",
40+
"month-grid": "#666666",
41+
"text": "#ffffff",
42+
"legend_text": "#000000",
43+
"text_lts": "#0C4B33",
44+
"bg": "none",
45+
}
46+
47+
CONFIG = {
48+
"pixels_per_year": 120,
49+
"bar_height": 32,
50+
"bar_v_spacing": 10,
51+
"padding_top": 30,
52+
"padding_bottom": 20,
53+
"padding_left": 20,
54+
"padding_right": 10,
55+
"font_family": "'Segoe UI', 'Arial'",
56+
"font_size": 18,
57+
"font_weight": "bold",
58+
"font_weight_lts": "600",
59+
"font_style_lts": "italic",
60+
"legend_box_size": 16,
61+
"legend_padding": 50,
62+
"text_padding_x": 10,
63+
"year_line_width": 3,
64+
"month_line_width": 1,
65+
}
66+
67+
68+
class Command(BaseCommand):
69+
70+
help = "Generate Django release roadmap SVG."
71+
72+
def add_arguments(self, parser):
73+
parser.add_argument(
74+
"--first-release", required=True, help="First release number, e.g., 4.2"
75+
)
76+
parser.add_argument(
77+
"--date",
78+
type=lambda str: dtime.datetime.strptime(str, "%Y-%m").date(),
79+
required=True,
80+
help="Release date in YYYY-MM format, e.g., 2023-04",
81+
)
82+
83+
def handle(self, *args, **options):
84+
render_svg(options["first_release"], options["date"])
85+
86+
87+
def get_chart_timeline(data: list, config: dict):
88+
89+
start_year = data[0]["release_date"].year
90+
91+
max_end_date = max(d["extended_end"] for d in data)
92+
93+
end_year = max_end_date.year + 1
94+
95+
total_years = end_year - start_year
96+
chart_width = total_years * config["pixels_per_year"]
97+
svg_width = chart_width + config["padding_left"] + config["padding_right"]
98+
99+
return start_year, end_year, int(svg_width)
100+
101+
102+
def calculate_dimensions(config: dict, num_releases: int) -> int:
103+
104+
chart_height = (
105+
config["padding_top"]
106+
+ config["padding_bottom"]
107+
+ (num_releases * config["bar_height"])
108+
+ ((num_releases - 1) * config["bar_v_spacing"])
109+
)
110+
return int(chart_height)
111+
112+
113+
def date_to_x(date: dtime.date, start_year: int, config: dict) -> float:
114+
115+
pixels_per_year = config["pixels_per_year"]
116+
pixels_per_block = pixels_per_year / 3.0
117+
start_x = config["padding_left"]
118+
119+
year_offset = (date.year - start_year) * pixels_per_year
120+
121+
if 1 <= date.month <= 4:
122+
123+
block_num = 0
124+
elif 5 <= date.month <= 8:
125+
126+
block_num = 1
127+
else:
128+
129+
block_num = 2
130+
131+
block_x_end = year_offset + ((block_num + 1) * pixels_per_block)
132+
133+
return start_x + block_x_end
134+
135+
136+
def generate_grids(start_year: int, end_year: int, config: dict) -> list:
137+
138+
grid_lines = []
139+
pixels_per_year = config["pixels_per_year"]
140+
pixels_per_block = pixels_per_year / 3.0
141+
142+
# Month labels only for the VERY FIRST set of lines
143+
FIRST_YEAR_MONTH_LABELS = {
144+
0: None,
145+
1: "April",
146+
2: "August",
147+
3: "December",
148+
}
149+
for year_index, year in enumerate(range(start_year, end_year)):
150+
year_x_start = config["padding_left"] + (year_index * pixels_per_year)
151+
152+
for line_index in range(4):
153+
x = year_x_start + (line_index * pixels_per_block)
154+
# Year label always on first line of each year
155+
top_label = str(year) if line_index == 0 else None
156+
# Month labels ONLY for the first year block
157+
if year_index == 0:
158+
bottom_label = FIRST_YEAR_MONTH_LABELS[line_index]
159+
else:
160+
bottom_label = None
161+
grid_lines.append(
162+
{
163+
"x": x,
164+
"width": (
165+
config["year_line_width"]
166+
if line_index == 0
167+
else config["month_line_width"]
168+
),
169+
"top_label": top_label,
170+
"bottom_label": bottom_label,
171+
"line-color": (
172+
COLORS["grid"] if line_index == 0 else COLORS["month-grid"]
173+
),
174+
}
175+
)
176+
return grid_lines
177+
178+
179+
def generate_release_data(first_release: str, release_date: dtime.date) -> list:
180+
"""
181+
Generate 8 Django-style releases starting from a given first release.
182+
first_release: "4.2"
183+
first_release_ym: "2023-04"
184+
"""
185+
major, minor = map(int, first_release.split("."))
186+
# Parse YYYY-MM → date
187+
188+
releases = []
189+
for i in range(8):
190+
curr_major = major + ((minor + i) // 3)
191+
curr_minor = (minor + i) % 3
192+
version = f"{curr_major}.{curr_minor}"
193+
is_lts = curr_minor == 2
194+
# Mainstream support lasts 8 months
195+
mainstream_end = release_date + relativedelta(months=8)
196+
# Extended support
197+
if is_lts:
198+
# LTS = 28 months after mainstream ends
199+
extended_end = mainstream_end + relativedelta(months=28)
200+
else:
201+
# Non-LTS = 8 months after mainstream ends
202+
extended_end = mainstream_end + relativedelta(months=8)
203+
releases.append(
204+
{
205+
"name": version,
206+
"is_lts": is_lts,
207+
"release_date": release_date,
208+
"mainstream_end": mainstream_end,
209+
"extended_end": extended_end,
210+
}
211+
)
212+
# Next release starts 8 months later
213+
release_date = release_date + relativedelta(months=8)
214+
return releases
215+
216+
217+
def generate_releases(data: list, start_year: int, config: dict) -> list:
218+
219+
releases_processed = []
220+
for i, release in enumerate(data):
221+
bar_y = config["padding_top"] + (
222+
i * (config["bar_height"] + config["bar_v_spacing"])
223+
)
224+
text_y_center = bar_y + (config["bar_height"] / 2) + (config["font_size"] / 3)
225+
226+
x_start = date_to_x(release["release_date"], start_year, config)
227+
x_end_mainstream = date_to_x(release["mainstream_end"], start_year, config)
228+
x_end_extended = date_to_x(release["extended_end"], start_year, config)
229+
230+
mainstream_bar = {
231+
"x": x_start,
232+
"y": bar_y,
233+
"width": x_end_mainstream - x_start,
234+
"height": config["bar_height"],
235+
"fill": COLORS["mainstream"],
236+
}
237+
238+
extended_bar = {
239+
"x": x_end_mainstream,
240+
"y": bar_y,
241+
"width": x_end_extended - x_end_mainstream,
242+
"height": config["bar_height"],
243+
"fill": COLORS["extended"],
244+
}
245+
246+
version_text = {
247+
"x": x_start + config["text_padding_x"],
248+
"y": text_y_center,
249+
"text": release["name"],
250+
}
251+
252+
lts_text = None
253+
if release.get("is_lts", False):
254+
lts_text = {
255+
"x": x_end_mainstream + config["text_padding_x"],
256+
"y": text_y_center,
257+
"text": "LTS",
258+
}
259+
260+
releases_processed.append(
261+
{
262+
"mainstream_bar": mainstream_bar,
263+
"extended_bar": extended_bar,
264+
"version_text": version_text,
265+
"lts_text": lts_text,
266+
}
267+
)
268+
return releases_processed
269+
270+
271+
def generate_legend(config: dict) -> dict:
272+
273+
legend_y = (
274+
config["padding_top"] + 260
275+
) # Fixed position for legend so that it doesn't conflict with month labels
276+
277+
width = config["legend_box_size"] + 100
278+
height = config["legend_box_size"] + 24
279+
280+
legend = {
281+
"mainstream_box": {
282+
"x": config["padding_left"],
283+
"y": legend_y - config["legend_box_size"] + 2,
284+
"size": config["legend_box_size"],
285+
"width": width,
286+
"height": height,
287+
"fill": COLORS["mainstream"],
288+
},
289+
"mainstream_text": {
290+
"x": config["padding_left"] + config["legend_box_size"] + 5,
291+
"y": legend_y,
292+
"fill": "#ffffff",
293+
"text": ["Mainstream", "Support"],
294+
},
295+
"extended_box": {
296+
"x": config["padding_left"] + width,
297+
"y": legend_y - config["legend_box_size"] + 2,
298+
"size": config["legend_box_size"],
299+
"width": width,
300+
"height": height,
301+
"fill": COLORS["extended"],
302+
},
303+
"extended_text": {
304+
"x": config["padding_left"] + config["legend_box_size"] + width + 8,
305+
"y": legend_y,
306+
"fill": "#000000",
307+
"text": ["Extended", "Support"],
308+
},
309+
}
310+
311+
return legend
312+
313+
314+
def render_svg(first_release: str, date: dtime.date):
315+
316+
data = generate_release_data(first_release, date)
317+
318+
start_year, end_year, svg_width = get_chart_timeline(data, CONFIG)
319+
svg_height = calculate_dimensions(CONFIG, len(data))
320+
321+
grid_lines = generate_grids(start_year, end_year, CONFIG)
322+
releases_processed = generate_releases(data, start_year, CONFIG)
323+
324+
legend = generate_legend(CONFIG)
325+
326+
env = Environment(loader=FileSystemLoader(TEMPLATE_DIR))
327+
template = env.get_template("template.svg.jinja")
328+
329+
output_svg = template.render(
330+
svg_width=svg_width,
331+
svg_height=svg_height,
332+
config=CONFIG,
333+
colors=COLORS,
334+
grid_lines=grid_lines,
335+
releases=releases_processed,
336+
legend=legend,
337+
)
338+
339+
OUTPUT_FILE.write_text(output_svg)

0 commit comments

Comments
 (0)