application: increment version number on bundle generation (#88373) #119
|
@ -15,15 +15,62 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django import forms
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from hobo.applications.models import Application, Version
|
||||
|
||||
|
||||
class GenerateForm(forms.Form):
|
||||
number = forms.CharField(label=_('Version Number'), max_length=100)
|
||||
number = forms.RegexField(
|
||||
label=_('Version Number'),
|
||||
max_length=100,
|
||||
regex=r'^\d+\.\d+$',
|
||||
help_text=_('The version number consists of two numbers separated by a dot. Example: 1.0'),
|
||||
)
|
||||
notes = forms.CharField(label=_('Version notes'), widget=forms.Textarea, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.latest_version = kwargs.pop('latest_version')
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.latest_version:
|
||||
try:
|
||||
old_version = [int(n) for n in self.latest_version.number.split('.')]
|
||||
except ValueError:
|
||||
old_version = None
|
||||
if old_version:
|
||||
self.initial['number'] = '.'.join(str(n) for n in old_version[:2])
|
||||
self.initial['notes'] = self.latest_version.notes
|
||||
|
||||
def clean_number(self):
|
||||
number = self.cleaned_data['number']
|
||||
if not self.latest_version:
|
||||
return number
|
||||
try:
|
||||
old_number = [int(n) for n in self.latest_version.number.split('.')]
|
||||
except ValueError:
|
||||
return number
|
||||
new_number = [int(n) for n in self.cleaned_data['number'].split('.')]
|
||||
if old_number[:2] > new_number:
|
||||
raise forms.ValidationError(
|
||||
_('The version number must be equal to or greater than the previous one.')
|
||||
)
|
||||
return number
|
||||
|
||||
def get_cleaned_number(self):
|
||||
number = [int(n) for n in self.cleaned_data['number'].split('.')]
|
||||
number.append(int(now().strftime('%Y%m%d')))
|
||||
pmarillonnet marked this conversation as resolved
Outdated
|
||||
if not self.latest_version:
|
||||
return '%s.%s.%s.0' % tuple(number)
|
||||
try:
|
||||
old_number = [int(n) for n in self.latest_version.number.split('.')]
|
||||
except ValueError:
|
||||
return '%s.%s.%s.0' % tuple(number)
|
||||
if number != old_number[:3]:
|
||||
return '%s.%s.%s.0' % tuple(number)
|
||||
last_part = old_number[3]
|
||||
return '%s.%s.%s.%s' % (*number, last_part + 1)
|
||||
|
||||
|
||||
class InstallForm(forms.Form):
|
||||
bundle = forms.FileField(label=_('Application'))
|
||||
|
|
|
@ -390,23 +390,17 @@ class GenerateView(FormView):
|
|||
form_class = GenerateForm
|
||||
template_name = 'hobo/applications/generate.html'
|
||||
|
||||
def get_initial(self):
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
self.app = get_object_or_404(Application, slug=self.kwargs['app_slug'], editable=True)
|
||||
version = self.app.version_set.order_by('last_update_timestamp').last()
|
||||
if version:
|
||||
self.initial['number'] = version.number
|
||||
self.initial['notes'] = version.notes
|
||||
return super().get_initial()
|
||||
kwargs['latest_version'] = self.app.version_set.order_by('last_update_timestamp').last()
|
||||
pmarillonnet marked this conversation as resolved
Outdated
pmarillonnet
commented
Pas compris pourquoi on prend le soin d’aller chercher la version dernièrement modifiée alors que le soin apporté à la cohérence des numéros de version fait qu’il suffirait d’aller prendre celle dont le champ Pas compris pourquoi on prend le soin d’aller chercher la version dernièrement modifiée alors que le soin apporté à la cohérence des numéros de version fait qu’il suffirait d’aller prendre celle dont le champ `number` est le plus élevé (?)
lguerin
commented
C'est pareil, la dernière modifiée est désormais aussi celle qui a le numéro de version le plus élevé. Avant c'était saisie libre on pouvait très bien revenir en arrière (et mettre "toto" si on voulait) C'est pareil, la dernière modifiée est désormais aussi celle qui a le numéro de version le plus élevé. Avant c'était saisie libre on pouvait très bien revenir en arrière (et mettre "toto" si on voulait)
pmarillonnet
commented
Ok et donc j’imagine qu’il y a encore en base des numéros saisis librement et qui échappent à cette conservation du tri <horodate de dernière modification> ↔ <numéro de version>. Ok. Ok et donc j’imagine qu’il y a encore en base des numéros saisis librement et qui échappent à cette conservation du tri <horodate de dernière modification> ↔ <numéro de version>. Ok.
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
app = self.app
|
||||
|
||||
latest_version = app.version_set.order_by('last_update_timestamp').last()
|
||||
if latest_version and latest_version.number == form.cleaned_data['number']:
|
||||
version = latest_version
|
||||
else:
|
||||
version = Version(application=app)
|
||||
version.number = form.cleaned_data['number']
|
||||
version = Version(application=app)
|
||||
version.number = form.get_cleaned_number()
|
||||
version.notes = form.cleaned_data['notes']
|
||||
version.save()
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import base64
|
||||
import copy
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import random
|
||||
|
@ -9,6 +10,7 @@ import tarfile
|
|||
import httmock
|
||||
import pytest
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.timezone import now
|
||||
from httmock import HTTMock
|
||||
from pyquery import PyQuery
|
||||
from webtest import Upload
|
||||
|
@ -323,7 +325,7 @@ def test_create_application(app, admin_user, settings, analyze):
|
|||
resp = resp.form.submit().follow()
|
||||
assert 'Test Card' in resp.text
|
||||
version = Version.objects.latest('pk')
|
||||
assert version.number == '1.0'
|
||||
assert version.number == '1.0.%s.0' % now().strftime('%Y%m%d')
|
||||
assert version.notes == 'Foo bar blah.'
|
||||
resp = resp.click('Download')
|
||||
assert resp.content_type == 'application/x-tar'
|
||||
|
@ -332,44 +334,21 @@ def test_create_application(app, admin_user, settings, analyze):
|
|||
assert b'<carddef/>' in resp.content
|
||||
assert b'"icon": null' in resp.content
|
||||
assert b'"documentation_url": "http://foo.bar"' in resp.content
|
||||
assert b'"version_number": "1.0"' in resp.content
|
||||
assert b'"version_number": "1.0.%s.0"' % now().strftime('%Y%m%d').encode() in resp.content
|
||||
assert b'"version_notes": "Foo bar blah."' in resp.content
|
||||
assert b'"visible": false' in resp.content
|
||||
|
||||
resp = app.get('/applications/manifest/test/versions/')
|
||||
versions = [e.text() for e in resp.pyquery('h3').items()]
|
||||
assert versions.count('1.0') == 1
|
||||
assert versions.count('1.0.%s.0' % now().strftime('%Y%m%d')) == 1
|
||||
assert resp.text.count('Creating application bundle') == 1
|
||||
assert 'Compare' not in resp
|
||||
resp = resp.click(href='/applications/manifest/test/download/%s/' % version.pk)
|
||||
assert resp.content_type == 'application/x-tar'
|
||||
assert resp.headers['Content-Disposition'] == 'attachment; filename="test-1.0.tar"'
|
||||
assert b'"version_number": "1.0"' in resp.content
|
||||
|
||||
# generate again without changing version number
|
||||
resp = app.get('/applications/manifest/test/generate/')
|
||||
assert resp.form['number'].value == '1.0' # last one
|
||||
assert resp.form['notes'].value == 'Foo bar blah.' # last one
|
||||
resp.form['notes'] = 'Foo bar blahha.'
|
||||
resp = resp.form.submit().follow()
|
||||
same_version = Version.objects.latest('pk')
|
||||
assert same_version.number == '1.0'
|
||||
assert same_version.notes == 'Foo bar blahha.'
|
||||
assert same_version.pk == version.pk
|
||||
resp = resp.click('Download')
|
||||
assert resp.content_type == 'application/x-tar'
|
||||
assert b'"version_number": "1.0"' in resp.content
|
||||
assert b'"version_notes": "Foo bar blahha."' in resp.content
|
||||
|
||||
resp = app.get('/applications/manifest/test/versions/')
|
||||
versions = [e.text() for e in resp.pyquery('h3').items()]
|
||||
assert versions.count('1.0') == 1
|
||||
assert resp.text.count('Creating application bundle') == 2
|
||||
assert 'Compare' not in resp
|
||||
resp = resp.click(href='/applications/manifest/test/download/%s/' % same_version.pk)
|
||||
assert resp.content_type == 'application/x-tar'
|
||||
assert resp.headers['Content-Disposition'] == 'attachment; filename="test-1.0.tar"'
|
||||
assert b'"version_number": "1.0"' in resp.content
|
||||
assert resp.headers[
|
||||
'Content-Disposition'
|
||||
] == 'attachment; filename="test-1.0.%s.0.tar"' % now().strftime('%Y%m%d')
|
||||
assert b'"version_number": "1.0.%s.0"' % now().strftime('%Y%m%d').encode() in resp.content
|
||||
|
||||
# add an icon
|
||||
resp = app.get('/applications/manifest/test/metadata/')
|
||||
|
@ -408,68 +387,88 @@ def test_create_application(app, admin_user, settings, analyze):
|
|||
assert b'<carddef/>' in resp.content
|
||||
assert b'"icon": "foo' in resp.content
|
||||
assert b'"documentation_url": ""' in resp.content
|
||||
assert b'"version_number": "2.0"' in resp.content
|
||||
assert b'"version_number": "2.0.%s.0"' % now().strftime('%Y%m%d').encode() in resp.content
|
||||
assert b'"version_notes": "Foo bar blah. But with an icon."' in resp.content
|
||||
assert b'"visible": true' in resp.content
|
||||
version = Version.objects.latest('pk')
|
||||
assert version.number == '2.0'
|
||||
assert version.number == '2.0.%s.0' % now().strftime('%Y%m%d')
|
||||
assert version.notes == 'Foo bar blah. But with an icon.'
|
||||
assert version.pk != same_version.pk
|
||||
|
||||
resp = app.get('/applications/manifest/test/versions/')
|
||||
versions = [e.text() for e in resp.pyquery('h3').items()]
|
||||
assert versions.count('1.0') == 1
|
||||
assert versions.count('2.0') == 1
|
||||
assert versions.count('1.0.%s.0' % now().strftime('%Y%m%d')) == 1
|
||||
assert versions.count('2.0.%s.0' % now().strftime('%Y%m%d')) == 1
|
||||
assert resp.text.count('Compare') == 2
|
||||
assert resp.text.count('Creating application bundle') == 3
|
||||
resp = resp.click(href='/applications/manifest/test/download/%s/' % same_version.pk)
|
||||
assert resp.text.count('Creating application bundle') == 2
|
||||
resp = resp.click(
|
||||
href='/applications/manifest/test/download/%s/' % Version.objects.get(number__startswith='1.0').pk
|
||||
)
|
||||
assert resp.content_type == 'application/x-tar'
|
||||
assert resp.headers['Content-Disposition'] == 'attachment; filename="test-1.0.tar"'
|
||||
assert b'"version_number": "1.0"' in resp.content
|
||||
assert resp.headers[
|
||||
'Content-Disposition'
|
||||
] == 'attachment; filename="test-1.0.%s.0.tar"' % now().strftime('%Y%m%d')
|
||||
assert b'"version_number": "1.0.%s.0"' % now().strftime('%Y%m%d').encode() in resp.content
|
||||
resp = app.get('/applications/manifest/test/versions/')
|
||||
resp = resp.click(href='/applications/manifest/test/download/%s/' % version.pk)
|
||||
resp = resp.click(
|
||||
href='/applications/manifest/test/download/%s/' % Version.objects.get(number__startswith='2.0').pk
|
||||
)
|
||||
assert resp.content_type == 'application/x-tar'
|
||||
assert resp.headers['Content-Disposition'] == 'attachment; filename="test-2.0.tar"'
|
||||
assert b'"version_number": "2.0"' in resp.content
|
||||
assert resp.headers[
|
||||
'Content-Disposition'
|
||||
] == 'attachment; filename="test-2.0.%s.0.tar"' % now().strftime('%Y%m%d')
|
||||
assert b'"version_number": "2.0.%s.0"' % now().strftime('%Y%m%d').encode() in resp.content
|
||||
|
||||
resp = app.get('/applications/manifest/test/generate/')
|
||||
assert resp.form['number'].value == '2.0' # last one
|
||||
assert resp.form['notes'].value == 'Foo bar blah. But with an icon.' # last one
|
||||
resp.form['number'] = '1.0' # old number
|
||||
resp = app.get('/applications/manifest/test/')
|
||||
resp = resp.click('Generate application bundle')
|
||||
resp.form['number'] = '2.0'
|
||||
resp.form['notes'] = 'Foo bar blah. But with an icon.'
|
||||
resp = resp.form.submit().follow()
|
||||
new_version = Version.objects.latest('pk')
|
||||
assert new_version.number == '1.0'
|
||||
assert new_version.notes == 'Foo bar blah. But with an icon.'
|
||||
assert new_version.pk != version.pk # new version created
|
||||
resp = resp.click('Download')
|
||||
assert resp.content_type == 'application/x-tar'
|
||||
# uncompressed tar, primitive check of contents
|
||||
assert b'<formdef/>' in resp.content
|
||||
assert b'<carddef/>' in resp.content
|
||||
assert b'"icon": "foo' in resp.content
|
||||
assert b'"documentation_url": ""' in resp.content
|
||||
assert b'"version_number": "2.0.%s.1"' % now().strftime('%Y%m%d').encode() in resp.content
|
||||
assert b'"version_notes": "Foo bar blah. But with an icon."' in resp.content
|
||||
assert b'"visible": true' in resp.content
|
||||
version = Version.objects.latest('pk')
|
||||
assert version.number == '2.0.%s.1' % now().strftime('%Y%m%d')
|
||||
assert version.notes == 'Foo bar blah. But with an icon.'
|
||||
|
||||
resp = app.get('/applications/manifest/test/versions/')
|
||||
versions = [e.text() for e in resp.pyquery('h3').items()]
|
||||
assert versions.count('1.0') == 2
|
||||
assert versions.count('2.0') == 1
|
||||
assert resp.text.count('Creating application bundle') == 4
|
||||
assert resp.text.count('Creating application bundle') == 3
|
||||
assert resp.text.count('Compare') == 3
|
||||
resp = resp.click('Compare', index=0)
|
||||
assert 'Compare version 1.0 to:' in resp
|
||||
assert 'Compare version 2.0.%s.1 to:' % now().strftime('%Y%m%d') in resp
|
||||
version1 = Version.objects.get(number__startswith='1.0')
|
||||
version2 = Version.objects.filter(number__startswith='2.0').latest('-pk')
|
||||
version3 = Version.objects.filter(number__startswith='2.0').latest('pk')
|
||||
assert resp.form['version'].options == [
|
||||
(str(version.pk), False, '2.0'),
|
||||
(str(same_version.pk), False, '1.0'),
|
||||
(str(version2.pk), False, version2.number),
|
||||
(str(version1.pk), False, version1.number),
|
||||
]
|
||||
resp = resp.form.submit()
|
||||
assert resp.location.endswith(
|
||||
'/applications/manifest/test/version/compare/?version1=%s&version2=%s'
|
||||
% (new_version.pk, version.pk)
|
||||
% (version3.pk, version2.pk)
|
||||
)
|
||||
resp = resp.follow()
|
||||
assert 'Version 2.0' in resp
|
||||
assert 'Version 1.0' in resp
|
||||
assert 'Version %s' % version3.number in resp
|
||||
assert 'Version %s' % version2.number in resp
|
||||
resp = resp.click('Compare elements definitions')
|
||||
assert 'Version 2.0' in resp
|
||||
assert 'Version 1.0' in resp
|
||||
assert 'Version %s' % version3.number in resp
|
||||
assert 'Version %s' % version2.number in resp
|
||||
assert (
|
||||
'https://wcs.example.invalid/api/export-import/forms/test-form/redirect/?application=test&version1=2.0&version2=1.0&compare'
|
||||
'https://wcs.example.invalid/api/export-import/forms/test-form/redirect/?application=test&version1=%s&version2=%s&compare'
|
||||
% (version2.number, version3.number)
|
||||
in resp
|
||||
)
|
||||
assert (
|
||||
'https://wcs.example.invalid/api/export-import/cards/test-card/redirect/?application=test&version1=2.0&version2=1.0&compare'
|
||||
'https://wcs.example.invalid/api/export-import/cards/test-card/redirect/?application=test&version1=%s&version2=%s&compare'
|
||||
% (version2.number, version3.number)
|
||||
in resp
|
||||
)
|
||||
assert 'Test Form <span class="extra-info">- Form</span>' in resp
|
||||
|
@ -479,16 +478,18 @@ def test_create_application(app, admin_user, settings, analyze):
|
|||
Element.objects.filter(type='cards').delete()
|
||||
resp = app.get(
|
||||
'/applications/manifest/test/version/compare/?version1=%s&version2=%s&mode=elements'
|
||||
% (new_version.pk, version.pk)
|
||||
% (version3.pk, version2.pk)
|
||||
)
|
||||
assert 'Version 2.0' in resp
|
||||
assert 'Version 1.0' in resp
|
||||
assert 'Version %s' % version3.number in resp
|
||||
assert 'Version %s' % version2.number in resp
|
||||
assert (
|
||||
'https://wcs.example.invalid/api/export-import/forms/test-form/redirect/?application=test&version1=2.0&version2=1.0&compare'
|
||||
'https://wcs.example.invalid/api/export-import/forms/test-form/redirect/?application=test&version1=%s&version2=%s&compare'
|
||||
% (version2.number, version3.number)
|
||||
in resp
|
||||
)
|
||||
assert (
|
||||
'https://wcs.example.invalid/api/export-import/cards/test-card/redirect/?application=test&version1=2.0&version2=1.0&compare'
|
||||
'https://wcs.example.invalid/api/export-import/cards/test-card/redirect/?application=test&version1=%s&version2=%s&compare'
|
||||
% (version2.number, version3.number)
|
||||
not in resp
|
||||
)
|
||||
assert 'Test Form <span class="extra-info">- Form</span>' in resp
|
||||
|
@ -521,6 +522,123 @@ def test_create_application(app, admin_user, settings, analyze):
|
|||
app.get('/applications/manifest/test/delete/%s/' % application.relation_set.first().pk, status=404)
|
||||
|
||||
|
||||
def test_application_version_number(app, admin_user, settings):
|
||||
Wcs.objects.create(base_url='https://wcs.example.invalid', slug='foobar', title='Foobar')
|
||||
|
||||
settings.KNOWN_SERVICES = {
|
||||
'wcs': {
|
||||
'blah': {
|
||||
# simulate an instance from another collectivity
|
||||
'title': 'Unknown',
|
||||
'url': 'https://unknown.example.invalid/',
|
||||
'orig': 'example.org',
|
||||
'secret': 'xxx',
|
||||
},
|
||||
'foobar': {
|
||||
'title': 'Foobar',
|
||||
'url': 'https://wcs.example.invalid/',
|
||||
'orig': 'example.org',
|
||||
'secret': 'xxx',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
login(app)
|
||||
|
||||
application = Application.objects.create(name='Test', slug='test')
|
||||
resp = app.get('/applications/manifest/test/generate/')
|
||||
resp.form['number'] = 'X'
|
||||
resp = resp.form.submit()
|
||||
assert resp.context['form'].errors == {'number': ['Enter a valid value.']}
|
||||
resp.form['number'] = 'X.0'
|
||||
resp = resp.form.submit()
|
||||
assert resp.context['form'].errors == {'number': ['Enter a valid value.']}
|
||||
resp.form['number'] = '1.1.1'
|
||||
resp = resp.form.submit()
|
||||
assert resp.context['form'].errors == {'number': ['Enter a valid value.']}
|
||||
resp.form['number'] = '1.1'
|
||||
with StatefulHTTMock(mocked_http):
|
||||
resp.form.submit()
|
||||
last_version = Version.objects.latest('pk')
|
||||
assert last_version.application == application
|
||||
assert last_version.number == '1.1.%s.0' % now().strftime('%Y%m%d')
|
||||
|
||||
resp = app.get('/applications/manifest/test/generate/')
|
||||
assert resp.form['number'].value == '1.1'
|
||||
with StatefulHTTMock(mocked_http):
|
||||
resp.form.submit()
|
||||
last_version = Version.objects.latest('pk')
|
||||
assert last_version.application == application
|
||||
assert last_version.number == '1.1.%s.1' % now().strftime('%Y%m%d')
|
||||
|
||||
resp = app.get('/applications/manifest/test/generate/')
|
||||
assert resp.form['number'].value == '1.1'
|
||||
with StatefulHTTMock(mocked_http):
|
||||
resp.form.submit()
|
||||
last_version = Version.objects.latest('pk')
|
||||
assert last_version.application == application
|
||||
assert last_version.number == '1.1.%s.2' % now().strftime('%Y%m%d')
|
||||
|
||||
resp = app.get('/applications/manifest/test/generate/')
|
||||
resp.form['number'] = '1.0'
|
||||
resp = resp.form.submit()
|
||||
assert resp.context['form'].errors == {
|
||||
'number': ['The version number must be equal to or greater than the previous one.']
|
||||
}
|
||||
|
||||
last_version.number = '1.1.%s.2' % (now() - datetime.timedelta(days=1)).strftime('%Y%m%d')
|
||||
last_version.save()
|
||||
resp = app.get('/applications/manifest/test/generate/')
|
||||
assert resp.form['number'].value == '1.1'
|
||||
with StatefulHTTMock(mocked_http):
|
||||
resp.form.submit()
|
||||
last_version = Version.objects.latest('pk')
|
||||
assert last_version.application == application
|
||||
assert last_version.number == '1.1.%s.0' % now().strftime('%Y%m%d')
|
||||
|
||||
last_version.number = 'garbage'
|
||||
last_version.save()
|
||||
resp = app.get('/applications/manifest/test/generate/')
|
||||
assert resp.form['number'].value == ''
|
||||
resp.form['number'] = '1.1'
|
||||
with StatefulHTTMock(mocked_http):
|
||||
resp.form.submit()
|
||||
last_version = Version.objects.latest('pk')
|
||||
assert last_version.application == application
|
||||
assert last_version.number == '1.1.%s.0' % now().strftime('%Y%m%d')
|
||||
|
||||
last_version.number = '1'
|
||||
last_version.save()
|
||||
resp = app.get('/applications/manifest/test/generate/')
|
||||
assert resp.form['number'].value == '1'
|
||||
resp.form['number'] = '1.1'
|
||||
with StatefulHTTMock(mocked_http):
|
||||
resp.form.submit()
|
||||
last_version = Version.objects.latest('pk')
|
||||
assert last_version.application == application
|
||||
assert last_version.number == '1.1.%s.0' % now().strftime('%Y%m%d')
|
||||
|
||||
last_version.number = '1.1'
|
||||
last_version.save()
|
||||
resp = app.get('/applications/manifest/test/generate/')
|
||||
assert resp.form['number'].value == '1.1'
|
||||
with StatefulHTTMock(mocked_http):
|
||||
resp.form.submit()
|
||||
last_version = Version.objects.latest('pk')
|
||||
assert last_version.application == application
|
||||
assert last_version.number == '1.1.%s.0' % now().strftime('%Y%m%d')
|
||||
|
||||
last_version.number = '1.1.%s.1.1' % now().strftime('%Y%m%d')
|
||||
last_version.save()
|
||||
resp = app.get('/applications/manifest/test/generate/')
|
||||
assert resp.form['number'].value == '1.1'
|
||||
with StatefulHTTMock(mocked_http):
|
||||
resp.form.submit()
|
||||
last_version = Version.objects.latest('pk')
|
||||
assert last_version.application == application
|
||||
assert last_version.number == '1.1.%s.2' % now().strftime('%Y%m%d')
|
||||
|
||||
|
||||
def test_manifest_ordering(app, admin_user, settings):
|
||||
Wcs.objects.create(base_url='https://wcs.example.invalid', slug='foobar', title='Foobar')
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Dans la doc du format mangé par
strftime
(https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior) on dirait que%M
c’est les minutes de l’heure en cours, sur deux chiffres entre 00 et 59. Je crois que c’est%m
qu’on veut ici.zut, oui :)