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
1=== modified file 'developer_portal/admin.py'
2--- developer_portal/admin.py 2014-12-01 15:11:25 +0000
3+++ developer_portal/admin.py 2015-08-11 12:06:30 +0000
4@@ -3,10 +3,20 @@
5 from reversion.models import Revision, Version
6 from reversion.admin import VersionAdmin
7
8+from .models import ExternalDocsBranch
9+from django.core.management import call_command
10+
11 __all__ = (
12 )
13
14
15+def import_selected_external_docs_branches(modeladmin, request, queryset):
16+ for branch in queryset:
17+ call_command('import-external-docs-branches', branch.docs_namespace)
18+ import_selected_external_docs_branches.short_description = \
19+ "Import selected branches"
20+
21+
22 class RevisionAdmin(admin.ModelAdmin):
23 list_display = ('date_created', 'user', 'comment')
24 list_display_links = ('date_created', )
25@@ -15,6 +25,7 @@
26
27 admin.site.register(Revision, RevisionAdmin)
28
29+
30 class VersionAdmin(admin.ModelAdmin):
31 list_display = ('content_type', 'object_id')
32 list_display_links = ('object_id', )
33@@ -22,3 +33,10 @@
34
35 admin.site.register(Version, VersionAdmin)
36
37+
38+class ExternalDocsBranchAdmin(admin.ModelAdmin):
39+ list_display = ('lp_origin', 'docs_namespace')
40+ list_filter = ('lp_origin', 'docs_namespace')
41+ actions = [import_selected_external_docs_branches]
42+
43+admin.site.register(ExternalDocsBranch, ExternalDocsBranchAdmin)
44
45=== added file 'developer_portal/management/commands/import-external-docs-branches.py'
46--- developer_portal/management/commands/import-external-docs-branches.py 1970-01-01 00:00:00 +0000
47+++ developer_portal/management/commands/import-external-docs-branches.py 2015-08-11 12:06:30 +0000
48@@ -0,0 +1,309 @@
49+from django.core.management.base import BaseCommand
50+
51+from cms.api import create_page, add_plugin
52+from cms.models import Page, Title
53+from cms.utils import page_resolver
54+
55+from bs4 import BeautifulSoup
56+import codecs
57+import glob
58+import logging
59+import markdown
60+import os
61+import re
62+import shutil
63+import subprocess
64+import sys
65+import tempfile
66+
67+from developer_portal.models import ExternalDocsBranch
68+
69+DOCS_DIRNAME = 'docs'
70+
71+
72+class MarkdownFile:
73+ html = None
74+
75+ def __init__(self, fn, docs_namespace, slug_override=None):
76+ self.fn = fn
77+ self.docs_namespace = docs_namespace
78+ if slug_override:
79+ self.slug = slug_override
80+ else:
81+ self.slug = slugify(self.fn)
82+ self.full_url = os.path.join(self.docs_namespace, self.slug)
83+ with codecs.open(self.fn, 'r', encoding='utf-8') as f:
84+ self.html = markdown.markdown(
85+ f.read(),
86+ output_format="html5",
87+ extensions=['markdown.extensions.tables'])
88+ self.release_alias = self._get_release_alias()
89+ self.title = self._read_title()
90+ self._remove_body_and_html_tags()
91+ self._use_developer_site_style()
92+
93+ def _get_release_alias(self):
94+ alias = re.findall(r'/tmp/tmp\S+?/(\S+?)/%s/\S+?' % DOCS_DIRNAME,
95+ self.fn)
96+ return alias[0]
97+
98+ def _read_title(self):
99+ soup = BeautifulSoup(self.html, 'html5lib')
100+ if soup.title:
101+ return soup.title.text
102+ if soup.h1:
103+ return soup.h1.text
104+ return slugify(self.fn).replace('-', ' ').title()
105+
106+ def _remove_body_and_html_tags(self):
107+ self.html = re.sub(r"<html>\n\s<body>\n", "", self.html,
108+ flags=re.MULTILINE)
109+ self.html = re.sub(r"\s<\/body>\n<\/html>", "", self.html,
110+ flags=re.MULTILINE)
111+
112+ def _use_developer_site_style(self):
113+ begin = (u"<div class=\"row no-border\">"
114+ "\n<div class=\"eight-col\">\n")
115+ end = u"</div>\n</div>"
116+ self.html = begin + self.html + end
117+ self.html = self.html.replace(
118+ "<pre><code>",
119+ "</div><div class=\"twelve-col\"><pre><code>")
120+ self.html = self.html.replace(
121+ "</code></pre>",
122+ "</code></pre></div><div class=\"eight-col\">")
123+
124+ def replace_links(self, titles):
125+ for title in titles:
126+ url = u"/snappy/guides/%s/%s" % (
127+ self.release_alias, slugify(title))
128+ link = u"<a href=\"%s\">%s</a>" % (url, titles[title])
129+ self.html = self.html.replace(os.path.basename(title), link)
130+
131+ def publish(self):
132+ '''Publishes pages in their branch alias namespace.'''
133+ page = get_or_create_page(
134+ self.title, full_url=self.full_url, menu_title=self.title,
135+ html=self.html)
136+ page.publish('en')
137+
138+
139+class SnappyMarkdownFile(MarkdownFile):
140+ def __init__(self, fn, docs_namespace):
141+ MarkdownFile.__init__(self, fn, docs_namespace)
142+ self._make_snappy_mods()
143+
144+ def _make_snappy_mods(self):
145+ # Make sure the reader knows which documentation she is browsing
146+ if self.release_alias != 'current':
147+ before = (u"<div class=\"row no-border\">\n"
148+ "<div class=\"eight-col\">\n")
149+ after = (u"<div class=\"row no-border\">\n"
150+ "<div class=\"box pull-three three-col\">"
151+ "<p>You are browsing the Snappy <code>%s</code> "
152+ "documentation.</p>"
153+ "<p><a href=\"/snappy/guides/current/%s\">"
154+ "Back to the latest stable release &rsaquo;"
155+ "</a></p></div>\n"
156+ "<div class=\"eight-col\">\n") % (self.release_alias,
157+ self.slug, )
158+ self.html = self.html.replace(before, after)
159+
160+ def publish(self):
161+ if self.release_alias == "current":
162+ # Add a guides/<page> redirect to guides/current/<page>
163+ page = get_or_create_page(
164+ self.title, full_url=self.full_url.replace('/current', ''),
165+ redirect="/snappy/guides/current/%s" % (self.slug))
166+ page.publish('en')
167+ else:
168+ self.title += " (%s)" % (self.release_alias,)
169+ MarkdownFile.publish(self)
170+
171+
172+def slugify(filename):
173+ return os.path.basename(filename).replace('.md', '')
174+
175+
176+def get_branch_from_lp(origin, alias):
177+ return subprocess.call([
178+ 'bzr', 'checkout', '--lightweight', origin, alias])
179+
180+
181+class LocalBranch:
182+ titles = {}
183+
184+ def __init__(self, dirname, external_branch):
185+ self.dirname = dirname
186+ self.docs_path = os.path.join(self.dirname, DOCS_DIRNAME)
187+ self.doc_fns = glob.glob(self.docs_path+'/*.md')
188+ self.md_files = []
189+ self.external_branch = external_branch
190+ self.docs_namespace = self.external_branch.docs_namespace
191+ self.release_alias = os.path.basename(self.docs_namespace)
192+ self.index_doc_title = self.release_alias.title()
193+ self.index_doc = self.external_branch.index_doc
194+ self.markdown_class = MarkdownFile
195+
196+ def import_markdown(self):
197+ for doc_fn in self.doc_fns:
198+ if self.index_doc and os.path.basename(doc_fn) == self.index_doc:
199+ md_file = self.markdown_class(
200+ doc_fn,
201+ os.path.dirname(self.docs_namespace),
202+ slug_override=os.path.basename(self.docs_namespace))
203+ self.md_files.insert(0, md_file)
204+ else:
205+ md_file = self.markdown_class(doc_fn, self.docs_namespace)
206+ self.md_files += [md_file]
207+ self.titles[md_file.fn] = md_file.title
208+ if not self.index_doc:
209+ self._create_fake_index_doc()
210+
211+ def remove_old_pages(self):
212+ imported_page_urls = set([md_file.full_url
213+ for md_file in self.md_files])
214+ index_doc = page_resolver.get_page_queryset_from_path(
215+ self.docs_namespace)[0]
216+ # All pages in this namespace currently in the database
217+ db_pages = index_doc.get_descendants().all()
218+ delete_pages = []
219+ for db_page in db_pages:
220+ still_relevant = False
221+ for url in imported_page_urls:
222+ if url in db_page.get_absolute_url():
223+ still_relevant = True
224+ break
225+ # At this point we know that there's no match and the page
226+ # can be deleted.
227+ if not still_relevant:
228+ delete_pages += [db_page.id]
229+ # Only remove pages created by a script!
230+ Page.objects.filter(id__in=delete_pages, created_by="script").delete()
231+
232+ def publish(self):
233+ for md_file in self.md_files:
234+ md_file.publish()
235+
236+ def _create_fake_index_doc(self):
237+ '''Creates a fake index page at the top of the branches
238+ docs namespace.'''
239+
240+ if self.docs_namespace == "current":
241+ redirect = "/snappy/guides"
242+ else:
243+ redirect = None
244+
245+ in_navigation = False
246+ menu_title = None
247+ list_pages = ""
248+ for page in self.md_files:
249+ list_pages += "<li><a href=\"%s\">%s</a></li>" \
250+ % (os.path.basename(page.full_url), page.title)
251+ landing = (
252+ u"<div class=\"row\"><div class=\"eight-col\">\n"
253+ "<p>This section contains documentation for the "
254+ "<code>%s</code> Snappy branch.</p>"
255+ "<p><ul class=\"list-ubuntu\">%s</ul></p>\n"
256+ "<p>Auto-imported from <a "
257+ "href=\"https://code.launchpad.net/snappy\">%s</a>.</p>\n"
258+ "</div></div>") % (self.release_alias, list_pages,
259+ self.external_branch.lp_origin)
260+ new_release_page = get_or_create_page(
261+ self.index_doc_title, full_url=self.docs_namespace,
262+ in_navigation=in_navigation, redirect=redirect, html=landing,
263+ menu_title=menu_title)
264+ new_release_page.publish('en')
265+
266+
267+class SnappyLocalBranch(LocalBranch):
268+ def __init__(self, dirname, external_branch):
269+ LocalBranch.__init__(self, dirname, external_branch)
270+ self.markdown_class = SnappyMarkdownFile
271+ self.index_doc_title = 'Snappy documentation'
272+ if self.release_alias != 'current':
273+ self.index_doc_title += ' (%s)' % self.release_alias
274+
275+ def import_markdown(self):
276+ LocalBranch.import_markdown(self)
277+ for md_file in self.md_files:
278+ md_file.replace_links(self.titles)
279+
280+
281+def get_or_create_page(title, full_url, menu_title=None,
282+ in_navigation=True, redirect=None, html=None):
283+ # First check if pages already exist.
284+ pages = Title.objects.select_related('page').filter(path__regex=full_url)
285+ if pages:
286+ page = pages[0].page
287+ page.title = title
288+ page.publisher_is_draft = True
289+ page.menu_title = menu_title
290+ page.in_navigation = in_navigation
291+ page.redirect = redirect
292+ if html:
293+ # We create the page, so we know there's just one placeholder
294+ placeholder = page.placeholders.all()[0]
295+ if placeholder.get_plugins():
296+ plugin = placeholder.get_plugins()[0].get_plugin_instance()[0]
297+ plugin.body = html
298+ plugin.save()
299+ else:
300+ add_plugin(placeholder, 'RawHtmlPlugin', 'en', body=html)
301+ else:
302+ parent_pages = Title.objects.select_related('page').filter(
303+ path__regex=os.path.dirname(full_url))
304+ if not parent_pages:
305+ print('Parent %s not found.' % os.path.dirname(full_url))
306+ sys.exit(1)
307+ parent = parent_pages[0].page
308+
309+ slug = os.path.basename(full_url)
310+ page = create_page(
311+ title, "default.html", "en", slug=slug, parent=parent,
312+ menu_title=menu_title, in_navigation=in_navigation,
313+ position="last-child", redirect=redirect)
314+ if html:
315+ placeholder = page.placeholders.get()
316+ add_plugin(placeholder, 'RawHtmlPlugin', 'en', body=html)
317+ return page
318+
319+
320+def import_branches(selection):
321+ if not ExternalDocsBranch.objects.count():
322+ logging.error('No branches registered in the '
323+ 'ExternalDocsBranch table yet.')
324+ return
325+ tempdir = tempfile.mkdtemp()
326+ for branch in ExternalDocsBranch.objects.filter(
327+ docs_namespace__regex=selection):
328+ checkout_location = os.path.join(
329+ tempdir, os.path.basename(branch.docs_namespace))
330+ if get_branch_from_lp(branch.lp_origin, checkout_location) != 0:
331+ logging.error(
332+ 'Could not check out branch "%s".' % branch.lp_origin)
333+ shutil.rmtree(checkout_location)
334+ break
335+ if branch.lp_origin.startswith('lp:snappy'):
336+ local_branch = SnappyLocalBranch(checkout_location, branch)
337+ else:
338+ local_branch = LocalBranch(checkout_location, branch)
339+ local_branch.import_markdown()
340+ local_branch.publish()
341+ local_branch.remove_old_pages()
342+ shutil.rmtree(tempdir)
343+
344+
345+class Command(BaseCommand):
346+ help = "Import external branches for documentation."
347+
348+ def handle(*args, **options):
349+ logging.basicConfig(
350+ level=logging.ERROR,
351+ format='%(asctime)s %(levelname)-8s %(message)s',
352+ datefmt='%F %T')
353+ if len(args) < 2 or args[1] == "all":
354+ selection = '.*'
355+ else:
356+ selection = args[1]
357+ import_branches(selection)
358
359=== added file 'developer_portal/migrations/0003_add_external_docs_branches.py'
360--- developer_portal/migrations/0003_add_external_docs_branches.py 1970-01-01 00:00:00 +0000
361+++ developer_portal/migrations/0003_add_external_docs_branches.py 2015-08-11 12:06:30 +0000
362@@ -0,0 +1,62 @@
363+# -*- coding: utf-8 -*-
364+from south.utils import datetime_utils as datetime
365+from south.db import db
366+from south.v2 import SchemaMigration
367+from django.db import models
368+
369+
370+class Migration(SchemaMigration):
371+
372+ def forwards(self, orm):
373+ # Adding model 'ExternalDocsBranch'
374+ db.create_table(u'developer_portal_externaldocsbranch', (
375+ (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
376+ ('lp_origin', self.gf('django.db.models.fields.CharField')(max_length=200)),
377+ ('docs_namespace', self.gf('django.db.models.fields.CharField')(max_length=120)),
378+ ('index_doc', self.gf('django.db.models.fields.CharField')(max_length=120, blank=True)),
379+ ))
380+ db.send_create_signal(u'developer_portal', ['ExternalDocsBranch'])
381+
382+
383+ def backwards(self, orm):
384+ # Deleting model 'ExternalDocsBranch'
385+ db.delete_table(u'developer_portal_externaldocsbranch')
386+
387+
388+ models = {
389+ 'cms.cmsplugin': {
390+ 'Meta': {'object_name': 'CMSPlugin'},
391+ 'changed_date': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
392+ 'creation_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
393+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
394+ 'language': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}),
395+ 'level': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
396+ 'lft': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
397+ 'parent': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cms.CMSPlugin']", 'null': 'True', 'blank': 'True'}),
398+ 'placeholder': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['cms.Placeholder']", 'null': 'True'}),
399+ 'plugin_type': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}),
400+ 'position': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True', 'blank': 'True'}),
401+ 'rght': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'}),
402+ 'tree_id': ('django.db.models.fields.PositiveIntegerField', [], {'db_index': 'True'})
403+ },
404+ 'cms.placeholder': {
405+ 'Meta': {'object_name': 'Placeholder'},
406+ 'default_width': ('django.db.models.fields.PositiveSmallIntegerField', [], {'null': 'True'}),
407+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
408+ 'slot': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'})
409+ },
410+ u'developer_portal.externaldocsbranch': {
411+ 'Meta': {'object_name': 'ExternalDocsBranch'},
412+ 'docs_namespace': ('django.db.models.fields.CharField', [], {'max_length': '120'}),
413+ u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
414+ 'index_doc': ('django.db.models.fields.CharField', [], {'max_length': '120', 'blank': 'True'}),
415+ 'lp_origin': ('django.db.models.fields.CharField', [], {'max_length': '200'})
416+ },
417+ u'developer_portal.rawhtml': {
418+ 'Meta': {'object_name': 'RawHtml'},
419+ 'body': ('django.db.models.fields.TextField', [], {}),
420+ u'cmsplugin_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['cms.CMSPlugin']", 'unique': 'True', 'primary_key': 'True'})
421+ }
422+ }
423+
424+ complete_apps = ['developer_portal']
425\ No newline at end of file
426
427=== modified file 'developer_portal/models.py'
428--- developer_portal/models.py 2015-01-19 16:29:47 +0000
429+++ developer_portal/models.py 2015-08-11 12:06:30 +0000
430@@ -1,10 +1,13 @@
431+from django.db import models
432+from django.utils.translation import ugettext_lazy as _
433
434 from cms.models import CMSPlugin
435 from djangocms_text_ckeditor.html import extract_images
436 from djangocms_text_ckeditor.models import AbstractText
437
438+
439 class RawHtml(AbstractText):
440-
441+
442 class Meta:
443 abstract = False
444
445@@ -13,3 +16,19 @@
446 body = extract_images(body, self)
447 self.body = body
448 AbstractText.save(self, *args, **kwargs)
449+
450+
451+class ExternalDocsBranch(models.Model):
452+ lp_origin = models.CharField(
453+ max_length=200,
454+ help_text=_('Launchpad branch location, ie: lp:snappy/15.04'))
455+ docs_namespace = models.CharField(
456+ max_length=120,
457+ help_text=_('Path alias we want to use for the docs, '
458+ 'ie "snappy/guides/15.04" or '
459+ '"snappy/guides/latest", etc.'))
460+ index_doc = models.CharField(
461+ max_length=120,
462+ help_text=_('File name of doc to be used as index document, '
463+ 'ie "intro.md"'),
464+ blank=True)
465
466=== modified file 'locale/developer_portal.pot'
467--- locale/developer_portal.pot 2015-05-11 20:00:59 +0000
468+++ locale/developer_portal.pot 2015-08-11 12:06:30 +0000
469@@ -8,7 +8,7 @@
470 msgstr ""
471 "Project-Id-Version: PACKAGE VERSION\n"
472 "Report-Msgid-Bugs-To: \n"
473-"POT-Creation-Date: 2015-04-24 09:17+0000\n"
474+"POT-Creation-Date: 2015-07-27 14:41+0000\n"
475 "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
476 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
477 "Language-Team: LANGUAGE <LL@li.org>\n"
478@@ -21,6 +21,16 @@
479 msgid "Raw HTML"
480 msgstr ""
481
482+#: developer_portal/models.py:24
483+msgid "Launchpad branch location, ie: lp:snappy/15.04"
484+msgstr ""
485+
486+#: developer_portal/models.py:27
487+msgid ""
488+"Path alias we want to use for the docs, ie \"snappy/guides/15.04\" or "
489+"\"snappy/guides/latest\", etc."
490+msgstr ""
491+
492 #: developer_portal/settings.py:187 developer_portal/settings.py:195
493 msgid "English"
494 msgstr ""
495
496=== modified file 'requirements.txt'
497--- requirements.txt 2015-02-26 15:53:30 +0000
498+++ requirements.txt 2015-08-11 12:06:30 +0000
499@@ -1,6 +1,8 @@
500 Django==1.6.8
501+Markdown==2.6.2
502 South==1.0.1
503 Pillow==2.6.1
504+beautifulsoup4==4.4.0
505 cmsplugin-zinnia==0.6
506 dj-database-url==0.3.0
507 django-admin-enhancer==0.1.3.1

Subscribers

People subscribed via source and target branches