Préparation des notes de mise à jour: pouvoir classer les tickets, faire un export (#77825) #3

Merged
lguerin merged 8 commits from wip/77825-release-notes into main 2023-05-26 09:33:29 +02:00
16 changed files with 385 additions and 92 deletions

View File

@ -7,7 +7,7 @@ repos:
- id: pyupgrade
args: ['--keep-percent-format', '--py39-plus']
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.10.0
rev: 1.13.0
hooks:
- id: django-upgrade
args: ['--target-version', '3.2']

23
README
View File

@ -33,10 +33,27 @@ Code Style
black is used to format the code, using thoses parameters:
black --target-version py37 --skip-string-normalization --line-length 110
black --target-version py39 --skip-string-normalization --line-length 110
There is .pre-commit-config.yaml to use pre-commit to automatically run black
before commits. (execute `pre-commit install` to install the git hook.)
isort is used to format the imports, using those parameters:
isort --profile black --line-length 110
pyupgrade is used to automatically upgrade syntax, using those parameters:
pyupgrade --keep-percent-format --py39-plus
django-upgrade is used to automatically upgrade Django syntax, using those parameters:
django-upgrade --target-version 3.2
djhtml is used to automatically indent html files, using those parameters:
djhtml --tabwidth 2
There is .pre-commit-config.yaml to use pre-commit to automatically run black,
isort, pyupgrade, django-upgrade and djhtml before commits. (execute
`pre-commit install` to install the git hook.)
License

View File

@ -8,41 +8,31 @@ from django.views.decorators.cache import never_cache
from .models import InstalledService, Module, Platform, Project, Service
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('title',)}
admin.site.register(Project, ProjectAdmin)
@admin.register(Platform)
class PlatformAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('title',)}
admin.site.register(Platform, PlatformAdmin)
@admin.register(Service)
class ServiceAdmin(admin.ModelAdmin):
prepopulated_fields = {'slug': ('title',)}
admin.site.register(Service, ServiceAdmin)
@admin.register(InstalledService)
class InstalledServiceAdmin(admin.ModelAdmin):
pass
admin.site.register(InstalledService, InstalledServiceAdmin)
@admin.register(Module)
class ModuleAdmin(admin.ModelAdmin):
pass
admin.site.register(Module, ModuleAdmin)
@never_cache
def login(request, *args, **kwargs):
try:

View File

@ -0,0 +1,13 @@
from django import forms
from .models import IssueInfo
class IssueInfoForm(forms.ModelForm):
class Meta:
model = IssueInfo
fields = [
'issue_type',
'doc_focus',
'wording',
]

View File

@ -0,0 +1,40 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0005_auto_20201216_1435'),
]
operations = [
migrations.CreateModel(
name='IssueInfo',
fields=[
(
'id',
models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
('issue_id', models.IntegerField()),
(
'issue_type',
models.CharField(
choices=[
('NEW', 'NEW (Nouveautés)'),
('BUGFIX', 'BUGFIX (Corrections)'),
('DEV', 'DEV (Développements)'),
('TECH', 'TECH (À ignorer)'),
],
max_length=10,
null=True,
verbose_name='Type de ticket',
),
),
(
'doc_focus',
models.BooleanField(
default=False, verbose_name='Attention à porter à la doc (la mettre à jour ?)'
),
),
],
),
]

View File

@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0006_issue_info'),
]
operations = [
migrations.AddField(
model_name='issueinfo',
name='wording',
field=models.TextField(blank=True, verbose_name='Description pour les notes de mise à jour'),
),
]

View File

@ -205,3 +205,18 @@ class InstalledVersion(models.Model):
).order_by('-timestamp')[0]
except IndexError:
return None
ISSUE_TYPES = [
('NEW', 'NEW (Nouveautés)'),
('BUGFIX', 'BUGFIX (Corrections)'),
('DEV', 'DEV (Développements)'),
('TECH', 'TECH (À ignorer)'),
]
class IssueInfo(models.Model):
issue_id = models.IntegerField()
issue_type = models.CharField('Type de ticket', max_length=10, choices=ISSUE_TYPES, null=True)
doc_focus = models.BooleanField('Attention à porter à la doc (la mettre à jour ?)', default=False)
wording = models.TextField('Description pour les notes de mise à jour', blank=True)

View File

@ -0,0 +1,39 @@
<html>
<body>
<h4>Nouveaut&eacute;s</h4>
<ul>
{% spaceless %}
{% for issue in issues %}
{% if issue.info.issue_type == 'NEW' %}
<li>{% if issue.info.wording %}{{ issue.info.wording }}{% else %}{{ issue.subject }} (<a href="{{ issue.url }}">#{{ issue.id }}</a>){% endif %}</li>
{% endif %}
{% endfor %}
{% endspaceless %}
</ul>
<h4>Corrections</h4>
<ul>
{% spaceless %}
{% for issue in issues %}
{% if issue.info.issue_type == 'BUGFIX' %}
<li>{% if issue.info.wording %}{{ issue.info.wording }}{% else %}{{ issue.subject }} (<a href="{{ issue.url }}">#{{ issue.id }}</a>){% endif %}</li>
{% endif %}
{% endfor %}
{% endspaceless %}
</ul>
<h4>D&eacute;veloppement</h4>
<ul>
{% spaceless %}
{% for issue in issues %}
{% if issue.info.issue_type == 'DEV' %}
<li>{% if issue.info.wording %}{{ issue.info.wording }}{% else %}{{ issue.subject }} (<a href="{{ issue.url }}">#{{ issue.id }}</a>){% endif %}</li>
{% endif %}
{% endfor %}
{% endspaceless %}
</ul>
</body>
</html>

View File

@ -0,0 +1,19 @@
<tr>
<td class="edit">
<a rel="popup" class="link-action-icon edit" href="{% url 'issue-edit-info' issue.id %}" data-inplace-submit="true">Editer</a>
</td>
<td class="doc">
{% if issue.info.doc_focus %}<span class="icon doc"></span>{% endif %}
</td>
<td class="type">
{{ issue.info.issue_type }}
</td>
<td class="issue"><a href="{{ issue.url }}">#{{ issue.id }}</a></td>
<td class="subject">
{{ issue.subject }}
{% if issue.info.wording %}<br /><i>{{ issue.info.wording }}</i>{% endif %}
</td>
<td class="modules">
<span class="modules">[&nbsp;{% for module in issue.modules.keys %}{{ module }}{% if not forloop.last %},&nbsp;{% endif %}{% endfor %}&nbsp;]</span>
</td>
</tr>

View File

@ -0,0 +1,16 @@
{% extends "scrutiny/base.html" %}
{% block appbar %}
<h2>Infos du ticket #{{ form.instance.issue_id }}</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">Enregistrer</button>
<a class="cancel" href="/">Annuler</a>
</div>
</form>
{% endblock %}

View File

@ -1,13 +1,5 @@
<ul class="issues">
<table class="main pk-compact-table issues">
{% for issue in issues %}
<li><a href="{{ issue.url }}">#{{ issue.id }}</a> : {{ issue.subject }}
{% spaceless %}
<span class="modules">[
{% for module in issue.modules.keys %}
{{ module }}{% if not forloop.last %}, {% endif %}
{% endfor %}
]</span>
{% endspaceless %}
</li>
{% include 'projects/issue_fragment.html' %}
{% endfor %}
</ul>

View File

@ -11,7 +11,13 @@
{% for day in history %}
<div data-issues-url-content="{% url 'issues-snippet' %}">
{% if day.modules %}
<h3>{{ day.day|date:"d/m/Y" }}{% if day.day == 'future' %}À venir{% endif %}</h3>
{% if day.day == 'future' %}
<h3>À venir</h3>
<a href="{% url 'project-summary-history-future' slug=object.slug %}">Exporter</a>
{% else %}
<h3>{{ day.day|date:"d/m/Y" }}</h3>
<a href="{% url 'project-summary-history-day' slug=object.slug year=day.day.year month=day.day.month day=day.day.day %}">Exporter</a>
{% endif %}
<p>
{% for module in day.modules.values %}
<span data-module-name="{{ module.name }}"
@ -21,9 +27,11 @@
</span>
{% if not forloop.last %}, {%endif %}
{% endfor %}
<ul class="issues loading">
<li><i>(récupération des tickets en cours)</i></li>
</ul>
<table class="main pk-compact-table issues loading">
<tbody>
<tr><td>(récupération des tickets en cours)</td></tr>
</tbody>
</table>
{% endif %}
</div>
{% endfor %}
@ -37,7 +45,7 @@
<script>
$(function() {
$('[data-issues-url-content]').each(function(idx, elem) {
var ul_issues = $(elem).find('ul.issues');
var table_issues = $(elem).find('table.issues');
var url = $(elem).data('issues-url-content');
var modules = Object();
$(elem).find('[data-module-name]').each(function(idx2, elem2) {
@ -54,10 +62,14 @@
data: JSON.stringify(modules),
dataType: 'html',
success: function(data) {
$(ul_issues).replaceWith(data);
$(table_issues).replaceWith(data);
}
});
});
$(document).on('gadjo:dialog-done', function(ev, data) {
var $target = $(ev.target);
$target.parents('tr').replaceWith(data.content);
});
});
</script>
{% endblock %}

View File

@ -1,6 +1,7 @@
from django.urls import path, re_path
from .views import (
IssueInfoUpdate,
IssuesSnippet,
ModuleDeploymentsView,
ModuleDiffView,
@ -8,6 +9,7 @@ from .views import (
ModulesView,
ProjectDetailView,
ProjectHistoryView,
ProjectSummaryHistoryDayView,
ProjectSummaryHistoryView,
api_issues_json,
module_deployments_json,
@ -19,6 +21,16 @@ urlpatterns = [
re_path(
r'^(?P<slug>[\w,-]+)/history$', ProjectSummaryHistoryView.as_view(), name='project-summary-history'
),
re_path(
r'^(?P<slug>[\w,-]+)/history/(?P<year>\d+)/(?P<month>\d+)/(?P<day>\d+)$',
ProjectSummaryHistoryDayView.as_view(),
name='project-summary-history-day',
),
re_path(
r'^(?P<slug>[\w,-]+)/history/future$',
ProjectSummaryHistoryDayView.as_view(),
name='project-summary-history-future',
),
re_path(r'^(?P<slug>[\w,-]+)/detailed-history$', ProjectHistoryView.as_view(), name='project-history'),
re_path(
r'^modules/(?P<name>[\w,-]+)/diff/(?P<commit1>[\w,\.-]+)/(?P<commit2>[\w,\.-]+)$',
@ -26,6 +38,11 @@ urlpatterns = [
name='module-diff',
),
path('issues/snippet/', IssuesSnippet.as_view(), name='issues-snippet'),
path(
'issues/<int:issue_id>/info',
IssueInfoUpdate.as_view(),
name='issue-edit-info',
),
re_path(
r'^modules/(?P<name>[\w,-]+)/issues/(?P<commit1>[\w,\.-]+)/(?P<commit2>[\w,\.-]+)$',
ModuleIssuesView.as_view(),

View File

@ -2,14 +2,22 @@ import datetime
import json
import re
from django.http import HttpResponse
from django import template
from django.http import Http404, HttpResponse, JsonResponse
from django.shortcuts import render
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView
from django.views.generic.base import TemplateView
from django.views.generic.detail import DetailView
from .models import InstalledService, InstalledVersion, Module, Project
from .utils import CommitAndIssues, decorate_commit_line, get_issue_deployment_status
from .forms import IssueInfoForm
from .models import InstalledService, InstalledVersion, IssueInfo, Module, Project
from .utils import CommitAndIssues, Issue, decorate_commit_line, get_issue_deployment_status
def is_ajax(request):
return request.headers.get('x-requested-with') == 'XMLHttpRequest'
class ProjectDetailView(DetailView):
@ -87,10 +95,7 @@ class ProjectHistoryView(DetailView):
return context
class ProjectSummaryHistoryView(DetailView):
model = Project
template_name_suffix = '_summary_history'
class ProjectSummaryHistoryMixin:
# XXX: add an 'interesting' attribute to module model?
interesting_modules = [
'publik-base-theme',
@ -112,9 +117,7 @@ class ProjectSummaryHistoryView(DetailView):
'godo.js',
]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
def get_history(self):
platforms = list(self.object.platform_set.all())
platform = platforms[-1]
installed_versions = list(
@ -131,7 +134,7 @@ class ProjectSummaryHistoryView(DetailView):
versions_and_day = {}
for installed in installed_versions:
installed_day = installed.timestamp.strftime('%Y-%m-%d')
if not installed_day in versions_and_day:
if installed_day not in versions_and_day:
versions_and_day[installed_day] = {'modules': {}, 'day': installed.timestamp.date()}
if installed.version.module.name in versions_and_day[installed_day]['modules']:
continue
@ -163,15 +166,16 @@ class ProjectSummaryHistoryView(DetailView):
future_versions = {'modules': {}, 'day': 'future'}
module_names = previous_versions.keys()
for module_name in module_names:
try:
installed_version = (
InstalledVersion.objects.filter(
version__module__name=module_name, service__platform=validation_platform
)
.exclude(version__version='')
.order_by('-timestamp')[0]
installed_version = (
InstalledVersion.objects.filter(
version__module__name=module_name, service__platform=validation_platform
)
except IndexError:
.exclude(version__version='')
.select_related('version')
.order_by('-timestamp')
.first()
)
if not installed_version:
continue
if not installed_version.version.version:
continue
@ -186,12 +190,84 @@ class ProjectSummaryHistoryView(DetailView):
if future_versions['modules']:
versions_sorted_by_day.append(future_versions)
return versions_sorted_by_day, platform
class IssuesMixin:
def get_issues(self, modules):
modules_by_name = {m.name: m for m in Module.objects.all()}
issues = {}
for module_info in modules.values():
module = modules_by_name[module_info['name']]
if not module.repository_url:
continue
commits = CommitAndIssues.get_for_commits(
module, str(module_info.get('previous_version')), str(module_info.get('current_version'))
)
for commit in commits:
for issue in commit.issues:
if issue.subject == '---': # private issue
continue
if not int(issue.id) in issues:
issues[int(issue.id)] = issue
else:
issue = issues[int(issue.id)]
if not hasattr(issues[int(issue.id)], 'modules'):
issues[int(issue.id)].modules = {}
issues[int(issue.id)].modules[module_info['name']] = True
issues_info = IssueInfo.objects.filter(issue_id__in=issues.keys())
for info in issues_info:
issues[int(info.issue_id)].info = info
issues = list(issues.values())
issues.sort(key=lambda x: int(x.id))
issues.sort(key=lambda x: sorted(x.modules.keys()))
return issues
class ProjectSummaryHistoryView(ProjectSummaryHistoryMixin, DetailView):
model = Project
template_name_suffix = '_summary_history'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
versions_sorted_by_day, platform = self.get_history()
context['platform'] = platform
context['history'] = reversed(versions_sorted_by_day)
return context
class ProjectSummaryHistoryDayView(ProjectSummaryHistoryMixin, IssuesMixin, DetailView):
model = Project
def get(self, request, *args, **kwargs):
self.object = self.get_object()
day = 'future'
if 'year' in kwargs:
day = datetime.date(int(kwargs['year']), int(kwargs['month']), int(kwargs['day']))
versions_sorted_by_day, platform = self.get_history()
version = None
for _version in versions_sorted_by_day:
if _version['day'] == day:
version = _version
break
if not version:
raise Http404
issues = self.get_issues(version['modules'])
return render(
request,
'projects/date_export.html',
{'issues': issues},
)
class ModulesView(TemplateView):
template_name = 'projects/modules.html'
@ -225,7 +301,7 @@ class ModuleDeploymentsView(TemplateView):
module = context['module']
for service in InstalledService.objects.all():
version = module.get_installed_version(service.platform, service.service)
if version and not version in context['versions']:
if version and version not in context['versions']:
if version.version.version:
context['versions'].append(version)
@ -239,7 +315,7 @@ def module_deployments_json(request, name, **kwargs):
module = Module.objects.get(name=name)
for service in InstalledService.objects.all():
version = module.get_installed_version(service.platform, service.service)
if version and not version in installed_versions:
if version and version not in installed_versions:
installed_versions.append(version)
json.dump(
@ -257,7 +333,7 @@ def module_deployments_json(request, name, **kwargs):
return response
class IssuesSnippet(TemplateView):
class IssuesSnippet(IssuesMixin, TemplateView):
template_name = 'projects/issues_snippet.html'
@csrf_exempt
@ -271,34 +347,39 @@ class IssuesSnippet(TemplateView):
context = super().get_context_data(**kwargs)
modules = json.loads(self.request.read().decode('utf-8'))
modules_by_name = {m.name: m for m in Module.objects.all()}
issues = {}
for module_info in modules.values():
module = modules_by_name[module_info['name']]
if not module.repository_url:
continue
commits = CommitAndIssues.get_for_commits(
module, str(module_info.get('previous_version')), str(module_info.get('current_version'))
)
for commit in commits:
for issue in commit.issues:
if issue.subject == '---': # private issue
continue
if not int(issue.id) in issues:
issues[int(issue.id)] = issue
else:
issue = issues[int(issue.id)]
if not hasattr(issues[int(issue.id)], 'modules'):
issues[int(issue.id)].modules = {}
issues[int(issue.id)].modules[module_info['name']] = True
context['issues'] = list(issues.values())
context['issues'].sort(key=lambda x: int(x.id))
context['issues'].sort(key=lambda x: sorted(x.modules.keys()))
issues = self.get_issues(modules)
context['issues'] = issues
return context
class IssueInfoUpdate(FormView):
form_class = IssueInfoForm
template_name = 'projects/issue_info_form.html'
success_url = '/'
def dispatch(self, request, *args, **kwargs):
self.issue = Issue(kwargs['issue_id'])
return super().dispatch(request, *args, **kwargs)
def get_form_kwargs(self):
info, created = IssueInfo.objects.get_or_create(issue_id=self.issue.id)
kwargs = super().get_form_kwargs()
kwargs['instance'] = info
return kwargs
def form_valid(self, form):
form.save()
self.issue.info = form.instance
if not is_ajax(self.request):
return super().form_valid(form)
tmpl = template.loader.select_template(['projects/issue_fragment.html'])
response = {
'content': tmpl.render({'issue': self.issue}, self.request),
}
return JsonResponse(response)
def api_issues_json(request, *args, **kwargs):
response = HttpResponse(content_type='application/json')
issue_id = kwargs.get('issue')

View File

@ -95,12 +95,9 @@ td.version.dim {
color: #ccc;
}
#history-summary ul.issues {
line-height: 130%;
}
#history-summary span.modules {
font-size: 80%;
color: #ccc;
color: #666;
}
@-webkit-keyframes cell-loading-pulse {
@ -111,19 +108,19 @@ td.version.dim {
to { width: 100%; }
}
ul.loading li {
table.loading td {
position: relative;
z-index: 10;
display: block;
width: 100%;
}
ul.loading li::after {
table.loading td::after {
content: "";
width: 100%;
position: absolute;
z-index: 0;
left: 0;
top: 0;
width: 0px;
background: rgba(200, 200, 200, 0.5);
height: 100%;
@ -131,6 +128,36 @@ ul.loading li::after {
animation: cell-loading-pulse 10s linear infinite alternate;
}
table.issues td.edit,
table.issues td.doc {
width: 10px;
}
table.issues td.type,
table.issues td.issue {
width: 50px;
}
table.issues td.modules {
width: 100px;
text-align: right;
}
table.issues td.edit a.link-action-icon,
table.issues td.doc span.icon {
display: block;
width: 1em;
overflow: hidden;
border: none;
}
table.issues td.edit a.link-action-icon.edit::before {
content: "\f044"; /* edit */
font-family: FontAwesome;
padding-right: 1ex;
}
table.issues td.doc span.icon.doc::before {
content: "\f15c"; /* fa-file-text */
font-family: FontAwesome;
padding-right: 1ex;
}
ul.user-info {
display: none;
}

View File

@ -11,7 +11,7 @@ admin.autodiscover()
urlpatterns = [
path('', scrutiny.views.home, name='home'),
re_path(r'^projects/', include(projects_urls)),
path('projects/', include(projects_urls)),
re_path(r'^admin/', admin.site.urls),
path('logout/', scrutiny.views.logout, name='auth_logout'),
path('login/', scrutiny.views.login, name='auth_login'),
@ -21,11 +21,11 @@ urlpatterns += staticfiles_urlpatterns()
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if 'mellon' in settings.INSTALLED_APPS:
urlpatterns.append(re_path(r'^accounts/mellon/', include('mellon.urls')))
urlpatterns.append(path('accounts/mellon/', include('mellon.urls')))
if settings.DEBUG and 'debug_toolbar' in settings.INSTALLED_APPS:
import debug_toolbar # pylint: disable=import-error
urlpatterns = [
re_path(r'^__debug__/', include(debug_toolbar.urls)),
path('__debug__/', include(debug_toolbar.urls)),
] + urlpatterns