Merge lp:~developer-ubuntu-com-dev/developer-ubuntu-com/snappy-docs-import into lp:developer-ubuntu-com

Proposed by Daniel Holbach
Status: Merged
Approved by: Daniel Holbach
Approved revision: 162
Merged at revision: 126
Proposed branch: lp:~developer-ubuntu-com-dev/developer-ubuntu-com/snappy-docs-import
Merge into: lp:developer-ubuntu-com
Diff against target: 507 lines (+422/-2)
6 files modified
developer_portal/admin.py (+18/-0)
developer_portal/management/commands/import-external-docs-branches.py (+309/-0)
developer_portal/migrations/0003_add_external_docs_branches.py (+62/-0)
developer_portal/models.py (+20/-1)
locale/developer_portal.pot (+11/-1)
requirements.txt (+2/-0)
To merge this branch: bzr merge lp:~developer-ubuntu-com-dev/developer-ubuntu-com/snappy-docs-import
Reviewer Review Type Date Requested Status
Daniel Holbach (community) Approve
David Callé Pending
Review via email: mp+265004@code.launchpad.net

Commit message

Add functionality to import external markdown docs (like snappy and snapcraft) automatically into developer site.

To post a comment you must log in.
Revision history for this message
Daniel Holbach (dholbach) wrote :

This is still WIP.

Revision history for this message
Daniel Holbach (dholbach) wrote :

David Callé noticed that SnappyMarkdownFile._make_snappy_mods doesn't work as expected.

review: Needs Fixing
Revision history for this message
Daniel Holbach (dholbach) wrote :

<davidcalle> dholbach, well, I'm not sure about the "Snappy" name for landing pages. I haven't found anything else. Oh, maybe the script itself should be renamed to not have snappy in its name.

Revision history for this message
David Callé (davidc3) wrote :

Added a few changes.

Lots of testing done with a dummy branch on the whole publication, page removal, doc name changes, etc. processes. Everything works great.

Revision history for this message
Daniel Holbach (dholbach) wrote :

<dholbach> davidcalle, I read the code again and I think I'm fine with it
 parts of it could be a bit simpler I feel, but for now I think it's good enough

review: Approve
Revision history for this message
Daniel Holbach (dholbach) wrote :

<davidcalle> dholbach, same for me. Let's merge it :) I'll try the staging deploy this afternoon.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'developer_portal/admin.py'
--- developer_portal/admin.py 2014-12-01 15:11:25 +0000
+++ developer_portal/admin.py 2015-08-11 12:06:30 +0000
@@ -3,10 +3,20 @@
3from reversion.models import Revision, Version3from reversion.models import Revision, Version
4from reversion.admin import VersionAdmin4from reversion.admin import VersionAdmin
55
6from .models import ExternalDocsBranch
7from django.core.management import call_command
8
6__all__ = (9__all__ = (
7)10)
811
912
13def import_selected_external_docs_branches(modeladmin, request, queryset):
14 for branch in queryset:
15 call_command('import-external-docs-branches', branch.docs_namespace)
16 import_selected_external_docs_branches.short_description = \
17 "Import selected branches"
18
19
10class RevisionAdmin(admin.ModelAdmin):20class RevisionAdmin(admin.ModelAdmin):
11 list_display = ('date_created', 'user', 'comment')21 list_display = ('date_created', 'user', 'comment')
12 list_display_links = ('date_created', )22 list_display_links = ('date_created', )
@@ -15,6 +25,7 @@
1525
16admin.site.register(Revision, RevisionAdmin)26admin.site.register(Revision, RevisionAdmin)
1727
28
18class VersionAdmin(admin.ModelAdmin):29class VersionAdmin(admin.ModelAdmin):
19 list_display = ('content_type', 'object_id')30 list_display = ('content_type', 'object_id')
20 list_display_links = ('object_id', )31 list_display_links = ('object_id', )
@@ -22,3 +33,10 @@
2233
23admin.site.register(Version, VersionAdmin)34admin.site.register(Version, VersionAdmin)
2435
36
37class ExternalDocsBranchAdmin(admin.ModelAdmin):
38 list_display = ('lp_origin', 'docs_namespace')
39 list_filter = ('lp_origin', 'docs_namespace')
40 actions = [import_selected_external_docs_branches]
41
42admin.site.register(ExternalDocsBranch, ExternalDocsBranchAdmin)
2543
=== added file 'developer_portal/management/commands/import-external-docs-branches.py'
--- developer_portal/management/commands/import-external-docs-branches.py 1970-01-01 00:00:00 +0000
+++ developer_portal/management/commands/import-external-docs-branches.py 2015-08-11 12:06:30 +0000
@@ -0,0 +1,309 @@
1from django.core.management.base import BaseCommand
2
3from cms.api import create_page, add_plugin
4from cms.models import Page, Title
5from cms.utils import page_resolver
6
7from bs4 import BeautifulSoup
8import codecs
9import glob
10import logging
11import markdown
12import os
13import re
14import shutil
15import subprocess
16import sys
17import tempfile
18
19from developer_portal.models import ExternalDocsBranch
20
21DOCS_DIRNAME = 'docs'
22
23
24class MarkdownFile:
25 html = None
26
27 def __init__(self, fn, docs_namespace, slug_override=None):
28 self.fn = fn
29 self.docs_namespace = docs_namespace
30 if slug_override:
31 self.slug = slug_override
32 else:
33 self.slug = slugify(self.fn)
34 self.full_url = os.path.join(self.docs_namespace, self.slug)
35 with codecs.open(self.fn, 'r', encoding='utf-8') as f:
36 self.html = markdown.markdown(
37 f.read(),
38 output_format="html5",
39 extensions=['markdown.extensions.tables'])
40 self.release_alias = self._get_release_alias()
41 self.title = self._read_title()
42 self._remove_body_and_html_tags()
43 self._use_developer_site_style()
44
45 def _get_release_alias(self):
46 alias = re.findall(r'/tmp/tmp\S+?/(\S+?)/%s/\S+?' % DOCS_DIRNAME,
47 self.fn)
48 return alias[0]
49
50 def _read_title(self):
51 soup = BeautifulSoup(self.html, 'html5lib')
52 if soup.title:
53 return soup.title.text
54 if soup.h1:
55 return soup.h1.text
56 return slugify(self.fn).replace('-', ' ').title()
57
58 def _remove_body_and_html_tags(self):
59 self.html = re.sub(r"<html>\n\s<body>\n", "", self.html,
60 flags=re.MULTILINE)
61 self.html = re.sub(r"\s<\/body>\n<\/html>", "", self.html,
62 flags=re.MULTILINE)
63
64 def _use_developer_site_style(self):
65 begin = (u"<div class=\"row no-border\">"
66 "\n<div class=\"eight-col\">\n")
67 end = u"</div>\n</div>"
68 self.html = begin + self.html + end
69 self.html = self.html.replace(
70 "<pre><code>",
71 "</div><div class=\"twelve-col\"><pre><code>")
72 self.html = self.html.replace(
73 "</code></pre>",
74 "</code></pre></div><div class=\"eight-col\">")
75
76 def replace_links(self, titles):
77 for title in titles:
78 url = u"/snappy/guides/%s/%s" % (
79 self.release_alias, slugify(title))
80 link = u"<a href=\"%s\">%s</a>" % (url, titles[title])
81 self.html = self.html.replace(os.path.basename(title), link)
82
83 def publish(self):
84 '''Publishes pages in their branch alias namespace.'''
85 page = get_or_create_page(
86 self.title, full_url=self.full_url, menu_title=self.title,
87 html=self.html)
88 page.publish('en')
89
90
91class SnappyMarkdownFile(MarkdownFile):
92 def __init__(self, fn, docs_namespace):
93 MarkdownFile.__init__(self, fn, docs_namespace)
94 self._make_snappy_mods()
95
96 def _make_snappy_mods(self):
97 # Make sure the reader knows which documentation she is browsing
98 if self.release_alias != 'current':
99 before = (u"<div class=\"row no-border\">\n"
100 "<div class=\"eight-col\">\n")
101 after = (u"<div class=\"row no-border\">\n"
102 "<div class=\"box pull-three three-col\">"
103 "<p>You are browsing the Snappy <code>%s</code> "
104 "documentation.</p>"
105 "<p><a href=\"/snappy/guides/current/%s\">"
106 "Back to the latest stable release &rsaquo;"
107 "</a></p></div>\n"
108 "<div class=\"eight-col\">\n") % (self.release_alias,
109 self.slug, )
110 self.html = self.html.replace(before, after)
111
112 def publish(self):
113 if self.release_alias == "current":
114 # Add a guides/<page> redirect to guides/current/<page>
115 page = get_or_create_page(
116 self.title, full_url=self.full_url.replace('/current', ''),
117 redirect="/snappy/guides/current/%s" % (self.slug))
118 page.publish('en')
119 else:
120 self.title += " (%s)" % (self.release_alias,)
121 MarkdownFile.publish(self)
122
123
124def slugify(filename):
125 return os.path.basename(filename).replace('.md', '')
126
127
128def get_branch_from_lp(origin, alias):
129 return subprocess.call([
130 'bzr', 'checkout', '--lightweight', origin, alias])
131
132
133class LocalBranch:
134 titles = {}
135
136 def __init__(self, dirname, external_branch):
137 self.dirname = dirname
138 self.docs_path = os.path.join(self.dirname, DOCS_DIRNAME)
139 self.doc_fns = glob.glob(self.docs_path+'/*.md')
140 self.md_files = []
141 self.external_branch = external_branch
142 self.docs_namespace = self.external_branch.docs_namespace
143 self.release_alias = os.path.basename(self.docs_namespace)
144 self.index_doc_title = self.release_alias.title()
145 self.index_doc = self.external_branch.index_doc
146 self.markdown_class = MarkdownFile
147
148 def import_markdown(self):
149 for doc_fn in self.doc_fns:
150 if self.index_doc and os.path.basename(doc_fn) == self.index_doc:
151 md_file = self.markdown_class(
152 doc_fn,
153 os.path.dirname(self.docs_namespace),
154 slug_override=os.path.basename(self.docs_namespace))
155 self.md_files.insert(0, md_file)
156 else:
157 md_file = self.markdown_class(doc_fn, self.docs_namespace)
158 self.md_files += [md_file]
159 self.titles[md_file.fn] = md_file.title
160 if not self.index_doc:
161 self._create_fake_index_doc()
162
163 def remove_old_pages(self):
164 imported_page_urls = set([md_file.full_url
165 for md_file in self.md_files])
166 index_doc = page_resolver.get_page_queryset_from_path(
167 self.docs_namespace)[0]
168 # All pages in this namespace currently in the database
169 db_pages = index_doc.get_descendants().all()
170 delete_pages = []
171 for db_page in db_pages:
172 still_relevant = False
173 for url in imported_page_urls:
174 if url in db_page.get_absolute_url():
175 still_relevant = True
176 break
177 # At this point we know that there's no match and the page
178 # can be deleted.
179 if not still_relevant:
180 delete_pages += [db_page.id]
181 # Only remove pages created by a script!
182 Page.objects.filter(id__in=delete_pages, created_by="script").delete()
183
184 def publish(self):
185 for md_file in self.md_files:
186 md_file.publish()
187
188 def _create_fake_index_doc(self):
189 '''Creates a fake index page at the top of the branches
190 docs namespace.'''
191
192 if self.docs_namespace == "current":
193 redirect = "/snappy/guides"
194 else:
195 redirect = None
196
197 in_navigation = False
198 menu_title = None
199 list_pages = ""
200 for page in self.md_files:
201 list_pages += "<li><a href=\"%s\">%s</a></li>" \
202 % (os.path.basename(page.full_url), page.title)
203 landing = (
204 u"<div class=\"row\"><div class=\"eight-col\">\n"
205 "<p>This section contains documentation for the "
206 "<code>%s</code> Snappy branch.</p>"
207 "<p><ul class=\"list-ubuntu\">%s</ul></p>\n"
208 "<p>Auto-imported from <a "
209 "href=\"https://code.launchpad.net/snappy\">%s</a>.</p>\n"
210 "</div></div>") % (self.release_alias, list_pages,
211 self.external_branch.lp_origin)
212 new_release_page = get_or_create_page(
213 self.index_doc_title, full_url=self.docs_namespace,
214 in_navigation=in_navigation, redirect=redirect, html=landing,
215 menu_title=menu_title)
216 new_release_page.publish('en')
217
218
219class SnappyLocalBranch(LocalBranch):
220 def __init__(self, dirname, external_branch):
221 LocalBranch.__init__(self, dirname, external_branch)
222 self.markdown_class = SnappyMarkdownFile
223 self.index_doc_title = 'Snappy documentation'
224 if self.release_alias != 'current':
225 self.index_doc_title += ' (%s)' % self.release_alias
226
227 def import_markdown(self):
228 LocalBranch.import_markdown(self)
229 for md_file in self.md_files:
230 md_file.replace_links(self.titles)
231
232
233def get_or_create_page(title, full_url, menu_title=None,
234 in_navigation=True, redirect=None, html=None):
235 # First check if pages already exist.
236 pages = Title.objects.select_related('page').filter(path__regex=full_url)
237 if pages:
238 page = pages[0].page
239 page.title = title
240 page.publisher_is_draft = True
241 page.menu_title = menu_title
242 page.in_navigation = in_navigation
243 page.redirect = redirect
244 if html:
245 # We create the page, so we know there's just one placeholder
246 placeholder = page.placeholders.all()[0]
247 if placeholder.get_plugins():
248 plugin = placeholder.get_plugins()[0].get_plugin_instance()[0]
249 plugin.body = html
250 plugin.save()
251 else:
252 add_plugin(placeholder, 'RawHtmlPlugin', 'en', body=html)
253 else:
254 parent_pages = Title.objects.select_related('page').filter(
255 path__regex=os.path.dirname(full_url))
256 if not parent_pages:
257 print('Parent %s not found.' % os.path.dirname(full_url))
258 sys.exit(1)
259 parent = parent_pages[0].page
260
261 slug = os.path.basename(full_url)
262 page = create_page(
263 title, "default.html", "en", slug=slug, parent=parent,
264 menu_title=menu_title, in_navigation=in_navigation,
265 position="last-child", redirect=redirect)
266 if html:
267 placeholder = page.placeholders.get()
268 add_plugin(placeholder, 'RawHtmlPlugin', 'en', body=html)
269 return page
270
271
272def import_branches(selection):
273 if not ExternalDocsBranch.objects.count():
274 logging.error('No branches registered in the '
275 'ExternalDocsBranch table yet.')
276 return
277 tempdir = tempfile.mkdtemp()
278 for branch in ExternalDocsBranch.objects.filter(
279 docs_namespace__regex=selection):
280 checkout_location = os.path.join(
281 tempdir, os.path.basename(branch.docs_namespace))
282 if get_branch_from_lp(branch.lp_origin, checkout_location) != 0:
283 logging.error(
284 'Could not check out branch "%s".' % branch.lp_origin)
285 shutil.rmtree(checkout_location)
286 break
287 if branch.lp_origin.startswith('lp:snappy'):
288 local_branch = SnappyLocalBranch(checkout_location, branch)
289 else:
290 local_branch = LocalBranch(checkout_location, branch)
291 local_branch.import_markdown()
292 local_branch.publish()
293 local_branch.remove_old_pages()
294 shutil.rmtree(tempdir)
295
296
297class Command(BaseCommand):
298 help = "Import external branches for documentation."
299
300 def handle(*args, **options):
301 logging.basicConfig(
302 level=logging.ERROR,
303 format='%(asctime)s %(levelname)-8s %(message)s',
304 datefmt='%F %T')
305 if len(args) < 2 or args[1] == "all":
306 selection = '.*'
307 else:
308 selection = args[1]
309 import_branches(selection)
0310
=== added file 'developer_portal/migrations/0003_add_external_docs_branches.py'
--- developer_portal/migrations/0003_add_external_docs_branches.py 1970-01-01 00:00:00 +0000
+++ developer_portal/migrations/0003_add_external_docs_branches.py 2015-08-11 12:06:30 +0000
@@ -0,0 +1,62 @@
1# -*- coding: utf-8 -*-
2from south.utils import datetime_utils as datetime
3from south.db import db
4from south.v2 import SchemaMigration
5from django.db import models
6
7
8class Migration(SchemaMigration):
9
10 def forwards(self, orm):
11 # Adding model 'ExternalDocsBranch'
12 db.create_table(u'developer_portal_externaldocsbranch', (
13 (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
14 ('lp_origin', self.gf('django.db.models.fields.CharField')(max_length=200)),
15 ('docs_namespace', self.gf('django.db.models.fields.CharField')(max_length=120)),
16 ('index_doc', self.gf('django.db.models.fields.CharField')(max_length=120, blank=True)),
17 ))
18 db.send_create_signal(u'developer_portal', ['ExternalDocsBranch'])
19
20
21 def backwards(self, orm):
22 # Deleting model 'ExternalDocsBranch'
23 db.delete_table(u'developer_portal_externaldocsbranch')
24
25
26 models = {
27 'cms.cmsplugin': {
28 'Meta': {'object_name': 'CMSPlugin'},
29 'changed_date': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
30 'creation_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
31 u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
32 'language': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
33 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
34 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
35 'parent': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cms.CMSPlugin']", 'null': 'True', 'blank': 'True'}),
36 'placeholder': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cms.Placeholder']", 'null': 'True'}),
37 'plugin_type': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
38 'position': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True', 'blank': 'True'}),
39 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
40 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'})
41 },
42 'cms.placeholder': {
43 'Meta': {'object_name': 'Placeholder'},
44 'default_width': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True'}),
45 u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
46 'slot': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
47 },
48 u'developer_portal.externaldocsbranch': {
49 'Meta': {'object_name': 'ExternalDocsBranch'},
50 'docs_namespace': ('django.db.models.fields.CharField', [], {'max_length': '120'}),
51 u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
52 'index_doc': ('django.db.models.fields.CharField', [], {'max_length': '120', 'blank': 'True'}),
53 'lp_origin': ('django.db.models.fields.CharField', [], {'max_length': '200'})
54 },
55 u'developer_portal.rawhtml': {
56 'Meta': {'object_name': 'RawHtml'},
57 'body': ('django.db.models.fields.TextField', [], {}),
58 u'cmsplugin_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['cms.CMSPlugin']", 'unique': 'True', 'primary_key': 'True'})
59 }
60 }
61
62 complete_apps = ['developer_portal']
0\ No newline at end of file63\ No newline at end of file
164
=== modified file 'developer_portal/models.py'
--- developer_portal/models.py 2015-01-19 16:29:47 +0000
+++ developer_portal/models.py 2015-08-11 12:06:30 +0000
@@ -1,10 +1,13 @@
1from django.db import models
2from django.utils.translation import ugettext_lazy as _
13
2from cms.models import CMSPlugin4from cms.models import CMSPlugin
3from djangocms_text_ckeditor.html import extract_images5from djangocms_text_ckeditor.html import extract_images
4from djangocms_text_ckeditor.models import AbstractText6from djangocms_text_ckeditor.models import AbstractText
57
8
6class RawHtml(AbstractText):9class RawHtml(AbstractText):
7 10
8 class Meta:11 class Meta:
9 abstract = False12 abstract = False
1013
@@ -13,3 +16,19 @@
13 body = extract_images(body, self)16 body = extract_images(body, self)
14 self.body = body17 self.body = body
15 AbstractText.save(self, *args, **kwargs)18 AbstractText.save(self, *args, **kwargs)
19
20
21class ExternalDocsBranch(models.Model):
22 lp_origin = models.CharField(
23 max_length=200,
24 help_text=_('Launchpad branch location, ie: lp:snappy/15.04'))
25 docs_namespace = models.CharField(
26 max_length=120,
27 help_text=_('Path alias we want to use for the docs, '
28 'ie "snappy/guides/15.04" or '
29 '"snappy/guides/latest", etc.'))
30 index_doc = models.CharField(
31 max_length=120,
32 help_text=_('File name of doc to be used as index document, '
33 'ie "intro.md"'),
34 blank=True)
1635
=== modified file 'locale/developer_portal.pot'
--- locale/developer_portal.pot 2015-05-11 20:00:59 +0000
+++ locale/developer_portal.pot 2015-08-11 12:06:30 +0000
@@ -8,7 +8,7 @@
8msgstr ""8msgstr ""
9"Project-Id-Version: PACKAGE VERSION\n"9"Project-Id-Version: PACKAGE VERSION\n"
10"Report-Msgid-Bugs-To: \n"10"Report-Msgid-Bugs-To: \n"
11"POT-Creation-Date: 2015-04-24 09:17+0000\n"11"POT-Creation-Date: 2015-07-27 14:41+0000\n"
12"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"12"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
13"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"13"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
14"Language-Team: LANGUAGE <LL@li.org>\n"14"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -21,6 +21,16 @@
21msgid "Raw HTML"21msgid "Raw HTML"
22msgstr ""22msgstr ""
2323
24#: developer_portal/models.py:24
25msgid "Launchpad branch location, ie: lp:snappy/15.04"
26msgstr ""
27
28#: developer_portal/models.py:27
29msgid ""
30"Path alias we want to use for the docs, ie \"snappy/guides/15.04\" or "
31"\"snappy/guides/latest\", etc."
32msgstr ""
33
24#: developer_portal/settings.py:187 developer_portal/settings.py:19534#: developer_portal/settings.py:187 developer_portal/settings.py:195
25msgid "English"35msgid "English"
26msgstr ""36msgstr ""
2737
=== modified file 'requirements.txt'
--- requirements.txt 2015-02-26 15:53:30 +0000
+++ requirements.txt 2015-08-11 12:06:30 +0000
@@ -1,6 +1,8 @@
1Django==1.6.81Django==1.6.8
2Markdown==2.6.2
2South==1.0.13South==1.0.1
3Pillow==2.6.14Pillow==2.6.1
5beautifulsoup4==4.4.0
4cmsplugin-zinnia==0.66cmsplugin-zinnia==0.6
5dj-database-url==0.3.07dj-database-url==0.3.0
6django-admin-enhancer==0.1.3.18django-admin-enhancer==0.1.3.1

Subscribers

People subscribed via source and target branches