Merge pull request #12 from loleg/feedler

Initial release of Feedler
This commit is contained in:
datalets 2017-07-04 11:19:40 +02:00 committed by GitHub
commit 021f7823f5
28 changed files with 543 additions and 31 deletions

20
feedler/__init__.py Normal file
View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
__author__ = 'Oleg Lavrovsky'
__email__ = 'oleg@datalets.ch'
__version__ = '0.1'
FEEDLER_APPS = (
# Wagtail apps
'wagtail.wagtailcore',
'wagtail.wagtailadmin',
'wagtail.contrib.modeladmin',
'wagtail.contrib.wagtailroutablepage',
'wagtail.api.v2',
# Third-party apps
'rest_framework',
# My apps
'feedler',
)

7
feedler/api.py Normal file
View file

@ -0,0 +1,7 @@
from wagtail.api.v2.router import WagtailAPIRouter
from .endpoints import EntriesAPIEndpoint
# Create the router. "wagtailapi" is the URL namespace
api_router = WagtailAPIRouter('wagtailapi')
api_router.register_endpoint('entries', EntriesAPIEndpoint)

5
feedler/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class FeedlerConfig(AppConfig):
name = 'feedler'

26
feedler/endpoints.py Normal file
View file

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
from wagtail.contrib.wagtailapi.endpoints import BaseAPIEndpoint
from wagtail.contrib.wagtailapi.serializers import BaseSerializer
from wagtail.contrib.wagtailapi.filters import FieldsFilter, OrderingFilter, SearchFilter
from wagtail.contrib.wagtailapi.pagination import WagtailPagination
from .models import Entry
class EntrySerializer(BaseSerializer):
pass
class EntriesAPIEndpoint(BaseAPIEndpoint):
base_serializer_class = EntrySerializer
filter_backends = [FieldsFilter, OrderingFilter, SearchFilter]
extra_api_fields = [
'title',
'author',
'link',
'visual',
'content',
'tags',
'published',
]
name = 'entries'
model = Entry

60
feedler/feedparser.py Normal file
View file

@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
from datetime import datetime
def parse(obj, raw, stream):
"""
Parse raw JSON implementation from the Feedly API
"""
obj.raw = raw
obj.stream = stream
obj.entry_id = raw['id']
# Date stamp handling
ts = raw['published'] / 1000
obj.published = datetime.fromtimestamp(ts)
# Authorship and title
obj.title = raw['title']
if 'author' in raw['origin']:
obj.author = raw['author']
elif 'title' in raw['origin']:
obj.author = raw['origin']['title']
# Parse links and references
if len(raw['alternate']) > 0:
obj.link = raw['alternate'][0]['href']
if 'thumbnail' in raw and len(raw['thumbnail']) > 0:
if 'url' in raw['thumbnail'][0]:
obj.visual = raw['thumbnail'][0]['url']
elif 'enclosure' in raw and len(raw['enclosure']) > 0:
if 'href' in raw['enclosure'][0]:
obj.visual = raw['enclosure'][0]['href']
elif 'visual' in raw and 'url' in raw['visual']:
obj.visual = raw['visual']['url']
if obj.visual.lower().strip() == 'none':
obj.visual = ''
# Collect text in nested JSON content
if 'content' in obj.raw:
obj.content = obj.raw['content']
else:
if 'summary' in obj.raw:
if 'content' in obj.raw['summary']:
obj.content = obj.raw['summary']['content']
else:
obj.content = obj.raw['summary']
else:
obj.content = ''
# Collect tags
tags = []
for tag in obj.raw['tags']:
if 'label' in tag:
label = tag['label'].replace(',','-')
label = label.strip().lower()
if len(label) > 3 and not label in tags:
tags.append(label)
obj.tags = ','.join(tags)
return obj

View file

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.3 on 2017-07-03 13:46
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '__latest__'),
]
operations = [
migrations.CreateModel(
name='Entry',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('raw', models.TextField(blank=True, editable=False)),
('updated', models.DateTimeField(auto_now=True)),
('published', models.DateTimeField(auto_now_add=True)),
('entry_id', models.CharField(blank=True, editable=False, max_length=255, unique=True)),
('title', models.CharField(max_length=255)),
('author', models.CharField(blank=True, max_length=255)),
('link', models.URLField()),
('visual', models.URLField(blank=True)),
('content', models.TextField()),
('tags', models.TextField(blank=True)),
],
options={
'verbose_name_plural': 'Entries',
},
),
migrations.CreateModel(
name='FeedlySettings',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('feedly_auth', models.TextField(blank=True, help_text='Your developer authorization key')),
('feedly_pages', models.IntegerField(blank=True, choices=[(1, '2'), (2, '5'), (3, '10'), (4, '50')], help_text='How many pages to fetch?', null=True)),
],
options={
'verbose_name': 'Feedly',
},
),
migrations.CreateModel(
name='Stream',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('ident', models.CharField(max_length=255)),
],
),
migrations.AddField(
model_name='feedlysettings',
name='feedly_stream',
field=models.ManyToManyField(to='feedler.Stream'),
),
migrations.AddField(
model_name='feedlysettings',
name='site',
field=models.OneToOneField(editable=False, on_delete=django.db.models.deletion.CASCADE, to='wagtailcore.Site'),
),
migrations.AddField(
model_name='entry',
name='stream',
field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, to='feedler.Stream', verbose_name='Original stream'),
),
]

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.3 on 2017-07-03 15:21
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import wagtail.wagtailcore.fields
class Migration(migrations.Migration):
dependencies = [
('feedler', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='FeedPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')),
('intro', wagtail.wagtailcore.fields.RichTextField(blank=True, default='')),
('stream', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='feedler.Stream', verbose_name='Filter to stream (optional)')),
],
options={
'verbose_name': 'Feeds',
},
bases=('wagtailcore.page',),
),
]

View file

View file

@ -0,0 +1,2 @@
from .models import *
from .admin import *

65
feedler/models/admin.py Normal file
View file

@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
import requests, json, codecs
from django.contrib import admin
from django.db import models
from django.db.models.signals import pre_save
from django.dispatch import receiver
from django.core.mail import send_mail
from wagtail.contrib.settings.models import BaseSetting, register_setting
from .models import Entry, Stream
import feedler.feedparser as feedparser
import logging
logger = logging.getLogger('feedler')
# Feedly integration module
@register_setting
class FeedlySettings(BaseSetting):
feedly_auth = models.TextField(
help_text='Your developer authorization key', blank=True)
feedly_pages = models.IntegerField(
choices=(
(1, '2'),
(2, '5'),
(3, '10'),
(4, '50'),
), blank=True, null=True,
help_text='How many pages to fetch?'
)
feedly_stream = models.ManyToManyField(Stream)
class Meta:
verbose_name = 'Feedly'
API_BASEURL = 'https://cloud.feedly.com/v3/streams/contents?streamId='
@receiver(pre_save, sender=FeedlySettings)
def handle_save_settings(sender, instance, *args, **kwargs):
if instance.feedly_auth:
streams = instance.feedly_stream.all()
for stream in streams:
# Start a request to download the feed
logger.info("Processing stream %s" % stream.title)
url = API_BASEURL + stream.ident
headers = {
'Authorization': 'OAuth ' + instance.feedly_auth
}
contents = requests.get(url, headers=headers).json()
if 'errorMessage' in contents:
raise PermissionError(contents['errorMessage'])
for raw_entry in contents['items']:
eid = raw_entry['id']
# Create or update data
try:
entry = Entry.objects.get(entry_id=eid)
logger.info("Updating entry '%s'" % eid)
except Entry.DoesNotExist:
logger.info("Adding entry '%s'" % eid)
entry = Entry()
entry = feedparser.parse(entry, raw_entry, stream)
entry.save()

67
feedler/models/models.py Normal file
View file

@ -0,0 +1,67 @@
# -*- coding: utf-8 -*-
from django.db import models
from wagtail.wagtailcore.models import Page, Orderable
from wagtail.wagtailadmin.edit_handlers import FieldPanel
from wagtail.wagtailcore.fields import RichTextField
class Stream(models.Model):
title = models.CharField(max_length=255)
ident = models.CharField(max_length=255)
def __str__(self):
return self.title
class Entry(models.Model):
"""Implementation of the Entry from the feedly API as generic Django model
"""
raw = models.TextField(blank=True, editable=False)
updated = models.DateTimeField(auto_now=True, editable=False)
published = models.DateTimeField(auto_now_add=True, editable=False)
entry_id = models.CharField(max_length=255, unique=True, blank=True, editable=False)
title = models.CharField(max_length=255)
author = models.CharField(max_length=255, blank=True)
link = models.URLField()
visual = models.URLField(blank=True)
content = models.TextField()
tags = models.TextField(blank=True)
stream = models.ForeignKey(Stream,
blank=True, on_delete=models.CASCADE,
verbose_name='Original stream')
class Meta:
verbose_name_plural = 'Entries'
class FeedPage(Page):
intro = RichTextField(default='', blank=True)
stream = models.ForeignKey(Stream, on_delete=models.PROTECT,
null=True, blank=True, verbose_name='Filter to stream (optional)')
content_panels = [
FieldPanel('title'),
FieldPanel('intro'),
FieldPanel('stream'),
]
@property
def feedentries(self):
if self.stream:
entries = Entry.objects.filter(stream=self.stream)
else:
entries = Entry.objects.all()
# Order by most recent date first
entries = entries.order_by('-published')
return entries[:10]
def get_context(self, request):
# Update template context
context = super(FeedPage, self).get_context(request)
context['feedentries'] = self.feedentries
return context
class Meta:
verbose_name = "Feeds"

View file

@ -0,0 +1,52 @@
{% extends "base.html" %}
{% load static wagtailcore_tags %}
{% block body_class %}template-{{ self.get_verbose_name|slugify }}{% endblock %}
{% block extra_css %}
{% endblock %}
{% block title %}Feeds{% endblock %}
{% block content %}
<section id="article-index" class="article-index-page">
<div class="container">
<h2>{{ page.title }}</h2>
{% if page.intro %}
<p class="lead">{{ page.intro|richtext }}</p>
{% endif %}
</div>
</section>
<!-- Page body -->
<section id="news" class="feedpage-body">
<div class="container">
<div class="row">
{% for entry in feedentries %}
<div class="col-md-4 col-sm-6 col-xs-12">
{% if entry.visual %}
<div class="panel panel-default">
<img src="{{ entry.visual }}">
{% else %}
<div class="panel panel-fulltext">
{% endif %}
<div class="panel-body">
<h3><span>{{ entry.title|striptags|truncatewords_html:10 }}</span></h3>
<p>
<em><small><span>{{ entry.author }}</span></small></em><br><br>
{{ entry.content|striptags|truncatewords_html:25 }}
</p>
</div>
<a href="{{ entry.link }}" target="_blank" class="fill"></a>
</div>
</div>
<!-- {{ entry.raw }} -->
{% empty %}
<!-- No news today -->
{% endfor %}
</div>
</div>
</section>
{% endblock %}

3
feedler/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
feedler/urls.py Normal file
View file

@ -0,0 +1,10 @@
from django.conf.urls import include, url
from django.conf import settings
from django.contrib import admin
from django.conf.urls.i18n import i18n_patterns
from .api import api_router
urlpatterns = [
url(r'^api/v2/', api_router.urls),
]

28
feedler/wagtail_hooks.py Normal file
View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from wagtail.contrib.modeladmin.options import (
ModelAdmin, modeladmin_register)
from .models import Entry, Stream
class EntryModelAdmin(ModelAdmin):
model = Entry
menu_icon = 'date'
menu_order = 200
add_to_settings_menu = False
exclude_from_explorer = True
list_display = ('published', 'title', 'author', 'tags')
list_filter = ('author', 'tags')
search_fields = ('title', 'author', 'content', 'tags')
modeladmin_register(EntryModelAdmin)
class StreamModelAdmin(ModelAdmin):
model = Stream
menu_icon = 'date'
menu_order = 1000
add_to_settings_menu = True
exclude_from_explorer = True
list_display = ('title', 'ident')
modeladmin_register(StreamModelAdmin)

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.3 on 2017-07-03 10:44
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('home', '0018_contact_contact_form'),
]
operations = [
migrations.AlterModelOptions(
name='dataletssettings',
options={'verbose_name': 'Get support'},
),
]

View file

@ -1,4 +1,4 @@
from .forms import *
from .models import *
from .snippets import *
from .settings import *
from .admin import *

View file

@ -7,6 +7,8 @@ from django.core.mail import send_mail
from wagtail.contrib.settings.models import BaseSetting, register_setting
# A simple feedback module built into the site admin
@register_setting
class DataletsSettings(BaseSetting):
feedback_question = models.TextField(
@ -23,7 +25,7 @@ class DataletsSettings(BaseSetting):
feedback_comment = models.TextField(
help_text='Any general feedback', blank=True)
class Meta:
verbose_name = 'Datalets'
verbose_name = 'Get support'
@receiver(pre_save, sender=DataletsSettings)
def handle_save_settings(sender, instance, *args, **kwargs):

View file

@ -62,10 +62,15 @@ class ArticleIndexPage(Page):
context['subcategories'] = subcategories
return context
parent_page_types = [
'home.ArticleIndexPage',
'home.HomePage'
]
subpage_types = [
'home.ArticlePage',
'home.ArticleIndexPage',
'home.ContactForm'
'home.ContactForm',
'wagtailcore.Page'
]
class Meta:
verbose_name = "Rubrik"
@ -145,6 +150,10 @@ class ArticlePage(Page):
MultiFieldPanel(Page.promote_panels, "Einstellungen"),
]
parent_page_types = [
'home.ArticleIndexPage',
'home.HomePage'
]
subpage_types = []
class Meta:
verbose_name = "Artikel"
@ -212,7 +221,7 @@ class HomePage(Page):
return articles[:4]
@property
def newsfeed(self):
def blogentries(self):
# Get list of latest news
curlang = translation.get_language()
if not curlang in ['de', 'fr']: curlang = 'de' # Default language
@ -221,15 +230,15 @@ class HomePage(Page):
entries = EntryPage.objects.live().descendant_of(parent[0])
# Order by most recent date first
entries = entries.order_by('-date')
return entries[:4]
return entries[:6]
def get_context(self, request):
# Update template context
context = super(HomePage, self).get_context(request)
context['featured'] = self.featured
context['newsfeed'] = self.newsfeed
context['blogentries'] = self.blogentries
return context
parent_page_types = []
parent_page_types = ['wagtailcore.Page']
class Meta:
verbose_name = "Frontpage"

View file

@ -3,7 +3,7 @@
<section id="news">
<div class="container">
<div class="row">
{% for entry in newsfeed %}
{% for entry in blogentries %}
<div class="col-md-4 col-sm-6 col-xs-12">
<div class="panel panel-default">
{% if entry.header_image %}
@ -15,7 +15,7 @@
{% if entry.excerpt %}
{{ entry.excerpt|striptags }}
{% else %}
{{ entry.body|striptags|truncatewords_html:70 }}
{{ entry.body|striptags|truncatewords_html:40 }}
{% endif %}
</p>
<a href="{% pageurl entry %}" class="btn btn-default btn-xs">Mehr erfahren</a>

View file

@ -20,6 +20,7 @@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{{ blog_page.title }}" />
<meta name="twitter:description" content="{{ blog_page.description }}" />
<link rel="alternate" type="application/rss+xml" title="{{ blog_page.title }}" href="{% feeds_url blog_page %}" />
{% endblock social_share %}
{% block content %}
@ -56,4 +57,8 @@
</div>
{% endwith %}
</section>
<a href="{% feeds_url blog_page %}" target="_blank" title="RSS">
<i class="fa fa-rss-square"></i> <span>RSS Feed</span>
</a>
{% endblock content %}

View file

@ -39,7 +39,10 @@ def top_menu(context, parent, calling_page=None):
menuitem.show_dropdown = has_menu_children(menuitem)
menuitem.active = (calling_page.url.startswith(menuitem.url)
if calling_page else False)
try:
menuitem.title = menuitem.trans_title
except AttributeError:
pass
return {
'calling_page': calling_page,
'menuitems': menuitems,
@ -49,7 +52,10 @@ def top_menu(context, parent, calling_page=None):
def menuitems_children(parent):
menuitems_children = parent.get_children().live().in_menu().specific()
for menuitem in menuitems_children:
try:
menuitem.title = menuitem.trans_title
except AttributeError:
pass
return menuitems_children
# Retrieves the children of the top menu items for the drop downs

View file

@ -28,6 +28,7 @@ INSTALLED_APPS = [
'wagtail.contrib.wagtailsearchpromotions',
'wagtail.contrib.wagtailroutablepage',
'wagtail.contrib.wagtailsitemaps',
'wagtail.contrib.modeladmin',
'wagtail.contrib.settings',
'wagtail.wagtailforms',
'wagtail.wagtailredirects',
@ -41,11 +42,15 @@ INSTALLED_APPS = [
'wagtail.wagtailadmin',
'wagtail.wagtailcore',
'wagtail.api.v2',
'rest_framework',
'modelcluster',
'compressor',
'taggit',
'puput',
'anymail',
'feedler',
'django.contrib.admin',
'django.contrib.auth',

View file

@ -2,21 +2,13 @@
// News overview
#news {
.panel-default {
.panel-default, .panel-fulltext {
font-size: 90%;
padding-top: 75%; /* 1:1 Aspect Ratio */
position: relative; /* If you want text inside of it */
overflow: hidden;
background: lighten($brand-primary, 10%);
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.panel-body {
position: absolute;
top: 0;
@ -24,10 +16,7 @@
width: 100%;
height: 100%;
padding: 0;
transform: translateY(60%);
background-color: rgba($brand-primary, .8);
transition: transform .65s;
h3, p {
color: white;
}
@ -58,9 +47,6 @@
text-align: center;
}
}
&:hover .panel-body {
transform: rotateY(0);
}
// expand link over the thumbnail
a.fill {
@ -72,6 +58,23 @@
font-size: 0;
}
}
.panel-default {
img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: auto;
}
.panel-body {
transform: translateY(60%);
transition: transform .65s;
}
&:hover .panel-body {
transform: rotateY(0);
}
}
}
// News detail article

View file

@ -1,5 +1,11 @@
$(document).ready(function() {
// All external links in a new window
$('a[href^="http"]').filter(function() {
return this.hostname && this.hostname !== location.hostname;
}).attr('target', '_blank');
// Initialise front page carousel component
$('.carousel-inner.slick').slick({
autoplay: true,
autoplaySpeed: '10000',
@ -12,4 +18,11 @@ $(document).ready(function() {
nextArrow: '<span class="arrow right glyphicon glyphicon-chevron-right" aria-hidden="true">Next</span>',
});
// Formatting of live news
$('.feedpage-body .panel').each(function() {
var hue = Math.floor(Math.random() * 360);
var pastel = 'hsl(' + hue + ', 100%, 87.5%)';
$(this).css('border-top', '3px solid ' + pastel);
});
});

View file

@ -6,13 +6,16 @@ from django.conf.urls.i18n import i18n_patterns
from wagtail.wagtailadmin import urls as wagtailadmin_urls
from wagtail.wagtaildocs import urls as wagtaildocs_urls
from wagtail.wagtailcore import urls as wagtail_urls
from puput import urls as puput_urls
from feedler import urls as feedler_urls
from publichealth.search import views as search_views
urlpatterns = [
url(r'', include(puput_urls)),
url(r'', include(feedler_urls)),
url(r'^django-admin/', include(admin.site.urls)),
url(r'^admin/', include(wagtailadmin_urls)),

View file

@ -1,8 +1,8 @@
# Updated: 30.5.2017
# Core
wagtail==1.10.1
Django==1.11.1
wagtail==1.11
Django==1.11.3
# Database
psycopg2==2.7.1
@ -19,8 +19,8 @@ django-redis==4.8.0
# Frontend
django-libsass==0.7
libsass==0.12.3
Pillow==4.1.1
libsass==0.13.2
Pillow==4.2.0
# Development tools
stellar==0.4.3

View file

@ -1,5 +1,6 @@
import os
from puput import PUPUT_APPS
from feedler import FEEDLER_APPS
WAGTAIL_SITE_NAME = 'Public Health'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -16,6 +17,7 @@ INSTALLED_APPS = (
'django.contrib.staticfiles',
)
INSTALLED_APPS += PUPUT_APPS
INSTALLED_APPS += FEEDLER_APPS
MIDDLEWARE_CLASSES = (
'django.contrib.sessions.middleware.SessionMiddleware',