Skip to content

Commit

Permalink
New endpoint to download markdown version of a release (#1466)
Browse files Browse the repository at this point in the history
* New endpoint to download markdown version of a release

* Remove release date from the template

* Fix linting on version.py

* Fix trailing whitespace

* Fix other linting error

* Comment codecov for now
  • Loading branch information
Xpirix authored Jul 5, 2024
1 parent 04c830b commit eca6001
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 11 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/build-push-images-latest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ jobs:
EOF
docker cp projecta_devweb_1:/home/web/django_project/coverage.xml ../coverage.xml
- name: Upload coverage to codecov
uses: codecov/codecov-action@v2
with:
fail_ci_if_error: true
# - name: Upload coverage to codecov
# uses: codecov/codecov-action@v2
# with:
# fail_ci_if_error: true

docker-builder:
# Only push if PR happens in the same repo
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,25 +224,25 @@ def handle(self, *args, **kwargs):
self.stdout.write('Entry\'s Media')
self.stdout.write('-' * 30)
self.stdout.write(
f'All Entry media : {len(all_entries_media)} files '
f'All Entry media: {len(all_entries_media)} files '
f'{round(all_entries_size / 1000000, 2)} MB.\n'
f'Unused Entry media : {len(unused_entries_media)} files '
f'Unused Entry media: {len(unused_entries_media)} files '
f'{round(unused_entries_size / 1000000, 2)} MB.'
)
self.stdout.write('\n')
self.stdout.write('All Media (exclude Entry model instance)')
self.stdout.write('-' * 30)
self.stdout.write(
f'All Entry media : {len(all_media)} files '
f'All Entry media: {len(all_media)} files '
f'{round(all_media_size / 1000000, 2)} MB.\n'
f'Unused Entry media : {len(unused_media)} files '
f'Unused Entry media: {len(unused_media)} files '
f'{round(unused_media_size / 1000000, 2)} MB.'
)

confirmation = get_input(
f'\nDelete unused media images and files: '
f'{len(unused_entries_media) + len(unused_media)} files, '
f'{round((unused_entries_size + unused_media_size)/1000000, 2)} '
f'{round((unused_entries_size + unused_media_size) / 1000000, 2)} '
f'MB? [Y] '
)

Expand Down
26 changes: 26 additions & 0 deletions django_project/changes/templates/version/detail-content-md.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{% load custom_markup %}
{% load thumbnail %}

<h1>
Changelog for {{ version.project }} {{ version.name }}
</h1>

{% if version.image_file %}
<img class="img-responsive img-rounded center-block"
src="{{ version.image_file.url }}"/>
{% endif %}

{% if version.description %}
{{ version.description|base_markdown }}
{% endif %}

{% for row in version.categories %}
{% if row.entries %}
<h2 class="text-muted">
{{ row.category.name }}
</h2>
{% for entry in row.entries %}
{% include "entry/includes/entry_detail_rst.html" %}
{% endfor %}
{% endif %}
{% endfor %}{# row loop #}
6 changes: 6 additions & 0 deletions django_project/changes/templates/version/detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ <h1>Entries (all)</h1>
<span style="font-size: 7pt;">R S T</span>
</a>
{% endif %}
<a class="btn btn-default btn-mini tooltip-toggle"
data-title="Download as Markdown"
href='{% url 'version-download-md' project_slug=version.project.slug slug=version.slug %}'>
<span class="glyphicon glyphicon-download"></span>
<span style="font-size: 7pt;">M D</span>
</a>
<a class="btn btn-default btn-mini tooltip-toggle"
data-title="Download as GNU Changelog" data-toggle="tooltip"
href='{% url 'version-download-gnu' project_slug=version.project.slug slug=version.slug %}'>
Expand Down
4 changes: 2 additions & 2 deletions django_project/changes/tests/test_github_pull_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,10 +295,10 @@ def test_download_all_referenced_images(self):
rgx = ('<img.*?https://user-images.githubusercontent.com/40058076/'
'106831433-dea95b80-66ca-11eb-8026-6823084d726e.png.*?/>')
is_match = re.match(rgx, entry.description)
desc = entry.description
self.assertIsNone(
is_match,
msg=f'image regex pattern {rgx} must be not in description '
f'{entry.description}'
msg=f'image regex pattern {rgx} must be not in description {desc}'
)

# check if another image is in description
Expand Down
17 changes: 17 additions & 0 deletions django_project/changes/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,23 @@ def test_VersionDownload_login(self, mocked_convert):
version_same_name_from_other_project)
self.assertEqual(response.status_code, 200)

@override_settings(VALID_DOMAIN=['testserver', ])
@mock.patch('pypandoc.convert', side_effect=mocked_convert)
def test_VersionDownloadMd(self, mocked_convert):
other_project = ProjectF.create(name='testproject2')
version_same_name_from_other_project = VersionF.create(
project=other_project,
name='1.0.1'
)
response = self.client.get(reverse('version-download-md', kwargs={
'slug': version_same_name_from_other_project.slug,
'project_slug': other_project.slug
}))
self.assertEqual(
response.context.get('version'),
version_same_name_from_other_project)
self.assertEqual(response.status_code, 200)

@override_settings(VALID_DOMAIN=['testserver', ])
@mock.patch('pypandoc.convert', side_effect=mocked_convert)
def test_VersionDownload_login_notfound(self, mocked_convert):
Expand Down
4 changes: 4 additions & 0 deletions django_project/changes/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
VersionListView,
VersionUpdateView,
VersionDownload,
VersionDownloadMd,
VersionDownloadGnu,
VersionSponsorDownload,

Expand Down Expand Up @@ -167,6 +168,9 @@
url(regex='^(?P<project_slug>[\w-]+)/version/(?P<slug>[\w.-]+)/download/$',
view=VersionDownload.as_view(),
name='version-download'),
url(regex='^(?P<project_slug>[\w-]+)/version/(?P<slug>[\w.-]+)/md/$',
view=VersionDownloadMd.as_view(),
name='version-download-md'),
url(regex='^(?P<project_slug>[\w-]+)/version/(?P<slug>[\w.-]+)/gnu/$',
view=VersionDownloadGnu.as_view(),
name='version-download-gnu'),
Expand Down
177 changes: 177 additions & 0 deletions django_project/changes/views/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,183 @@ def get_queryset(self):
return self.queryset


class VersionDownloadMd(VersionMixin, DetailView):
"""View to allow users to download Version page in Markdown format."""
template_name = 'version/detail-content-md.html'

def get_context_data(self, **kwargs):
"""Get the context data which is passed to a template.
:param kwargs: Any arguments to pass to the superclass.
:type kwargs: dict
:returns: Context data which will be passed to the template.
:rtype: dict
"""
context = super(VersionDownloadMd, self).get_context_data(**kwargs)
versions = self.get_object()
sponsors = {}

# group sponsors by sponsorship level
if versions.sponsors():
for sponsor in versions.sponsors():
if sponsor.sponsorship_level not in sponsors:
sponsors[sponsor.sponsorship_level] = []

sponsors[sponsor.sponsorship_level].append(sponsor.sponsor)

context['sponsors'] = sponsors
return context

def render_to_response(self, context, **response_kwargs):
"""Returns a Markdown document for a project Version page.
:param context:
:type context: dict
:param response_kwargs: Keyword Arguments
:param response_kwargs: dict
:returns: a Markdown document for a project Version page.
:rtype: HttpResponse
"""
version_obj = context.get('version')
# set the context flag for 'md_download'
context['md_download'] = True
# render the template
document = self.response_class(
request=self.request,
template=self.get_template_names(),
context=context,
**response_kwargs
)

# convert the html to markdown
converted_doc = pypandoc.convert(
document.rendered_content.encode('utf8', 'ignore'),
'md', format='html', extra_args=['--no-wrap'])
converted_doc = converted_doc.replace('/media/images/', 'images/')

# prepare the ZIP file
zip_file = self._prepare_zip_archive(converted_doc, version_obj)

# Grab the ZIP file from memory, make response with correct MIME-type
response = HttpResponse(
zip_file.getvalue(), content_type="application/x-zip-compressed")
# ..and correct content-disposition
response['Content-Disposition'] = (
'attachment; filename="{}-{}.zip"'.format(
version_obj.project.name, version_obj.name)
)

return response

def _convert_headers(self, markdown_content):
"""
Convert underlined sections in a Markdown document to proper headers.
Sections underlined with '=' become first-level headers '#'
Sections underlined with '-' become second-level headers '##'
Args:
markdown_content (str): The original Markdown content.
Returns:
str: The transformed Markdown content with headers.
"""
# Define patterns for first and second-level headers
first_level_pattern = r'^(.*)\n=+\n'
second_level_pattern = r'^(.*)\n-+\n'

# Replace first-level underlined sections with '#'
markdown_content = re.sub(
first_level_pattern,
r'# \1\n',
markdown_content,
flags=re.MULTILINE
)

# Replace second-level underlined sections with '##'
markdown_content = re.sub(
second_level_pattern,
r'## \1\n',
markdown_content,
flags=re.MULTILINE
)

return markdown_content

# noinspection PyMethodMayBeStatic
def _prepare_zip_archive(self, document, version_obj):
"""Prepare a ZIP file with the document and referenced images.
:param document:
:param version_obj: Instance of a version object.
:returns temporary path for the created zip file
:rtype: string
"""
# create in memory file-like object
temp_path = BytesIO()

# Convert headers
document = self._convert_headers(document)
# Remove CSS styles and classes
document = re.sub(r'\{.*?\}', '', document)
# Regular expression pattern for Markdown image syntax
pattern = re.compile(r'!\[.*?\]\((.*?)\)')
# Find all matches
images = pattern.findall(document)
if not images:
images = []

# create the ZIP file
with zipfile.ZipFile(temp_path, 'w') as zip_file:
# write all of the image files (read from disk)
for image in images:
try:
image_url = '{}/{}'.format(settings.MEDIA_ROOT, image)
zip_file.write(
image_url,
'{0}'.format(image)
)
except FileNotFoundError:
pass
# write the actual Markdown document
zip_file.writestr(
'index.md',
document)
return temp_path

def get_queryset(self):
"""Get the queryset for download.
This will search a specific version within a project.
Thus it will not raise duplicates when there is
another same version name from another project.
:returns: A queryset which is filtered to only show Version
from specific project.
:rtype: QuerySet
:raises: Http404
"""

if self.queryset is None:
project_slug = self.kwargs.get('project_slug', None)
slug = self.kwargs.get('slug', None)
if project_slug and slug:
try:
project = Project.objects.get(slug=project_slug)
queryset = Version.objects.filter(
project=project, slug=slug)
return queryset
except (Project.DoesNotExist, Version.DoesNotExist):
raise Http404('Sorry! We could not find your version!')
else:
raise Http404('Sorry! We could not find your version!')
return self.queryset


class VersionDownloadGnu(VersionMixin, DetailView):
"""A tabular list style view for a Version."""
context_object_name = 'version'
Expand Down

0 comments on commit eca6001

Please sign in to comment.