Merge first implementation
This commit is contained in:
parent
abacb72b30
commit
c508cca080
71 changed files with 3902 additions and 0 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,6 +8,7 @@
|
||||||
*.pyc
|
*.pyc
|
||||||
__pycache__/
|
__pycache__/
|
||||||
local_settings.py
|
local_settings.py
|
||||||
|
local.py
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
media
|
media
|
||||||
|
|
||||||
|
|
|
||||||
7
.isort.cfg
Normal file
7
.isort.cfg
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
[settings]
|
||||||
|
include_trailing_comma = true
|
||||||
|
line_length = 80
|
||||||
|
multi_line_output = 3
|
||||||
|
skip = env/
|
||||||
|
forced_separate=django, newsreader
|
||||||
|
lines_between_types=1
|
||||||
12
requirements/base.txt
Normal file
12
requirements/base.txt
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
certifi==2019.3.9
|
||||||
|
chardet==3.0.4
|
||||||
|
Django==2.2
|
||||||
|
feedparser==5.2.1
|
||||||
|
idna==2.8
|
||||||
|
pkg-resources==0.0.0
|
||||||
|
pytz==2018.9
|
||||||
|
requests==2.21.0
|
||||||
|
sqlparse==0.3.0
|
||||||
|
urllib3==1.24.1
|
||||||
|
psycopg2-binary==2.8.1
|
||||||
|
Pillow==6.0.0
|
||||||
4
requirements/dev.txt
Normal file
4
requirements/dev.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-r base.txt
|
||||||
|
|
||||||
|
factory-boy==2.12.0
|
||||||
|
freezegun==0.3.12
|
||||||
21
src/manage.py
Executable file
21
src/manage.py
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'newsreader.conf.base')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
0
src/newsreader/__init__.py
Normal file
0
src/newsreader/__init__.py
Normal file
0
src/newsreader/conf/__init__.py
Normal file
0
src/newsreader/conf/__init__.py
Normal file
111
src/newsreader/conf/base.py
Normal file
111
src/newsreader/conf/base.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""
|
||||||
|
Django settings for newsreader project.
|
||||||
|
|
||||||
|
Generated by "django-admin startproject" using Django 2.2.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/2.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/2.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||||
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = "^!7a2jq5j!exc-55vf$anx9^6ff6=u_ub5=5p1(1x47fix)syh"
|
||||||
|
|
||||||
|
# SECURITY WARNING: don"t run with debug turned on in production!
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ["127.0.0.1"]
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
# app modules
|
||||||
|
"newsreader.news.collection",
|
||||||
|
"newsreader.news.posts",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "newsreader.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "newsreader.wsgi.application"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.postgresql_psycopg2",
|
||||||
|
"NAME": "newsreader",
|
||||||
|
"USER": "newsreader",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
"NAME":
|
||||||
|
"django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/2.2/topics/i18n/
|
||||||
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
|
TIME_ZONE = "UTC"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_L10N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/2.2/howto/static-files/
|
||||||
|
STATIC_URL = "/static/"
|
||||||
12
src/newsreader/conf/dev.py
Normal file
12
src/newsreader/conf/dev.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from .base import *
|
||||||
|
|
||||||
|
# Development settings
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .local import *
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
0
src/newsreader/core/__init__.py
Normal file
0
src/newsreader/core/__init__.py
Normal file
3
src/newsreader/core/admin.py
Normal file
3
src/newsreader/core/admin.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
src/newsreader/core/apps.py
Normal file
5
src/newsreader/core/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
name = 'core'
|
||||||
0
src/newsreader/core/migrations/__init__.py
Normal file
0
src/newsreader/core/migrations/__init__.py
Normal file
13
src/newsreader/core/models.py
Normal file
13
src/newsreader/core/models.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class TimeStampedModel(models.Model):
|
||||||
|
"""
|
||||||
|
An abstract base class model that provides self-
|
||||||
|
updating ``created`` and ``modified`` fields.
|
||||||
|
"""
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
modified = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
3
src/newsreader/core/tests.py
Normal file
3
src/newsreader/core/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
src/newsreader/core/views.py
Normal file
3
src/newsreader/core/views.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
0
src/newsreader/news/__init__.py
Normal file
0
src/newsreader/news/__init__.py
Normal file
0
src/newsreader/news/collection/__init__.py
Normal file
0
src/newsreader/news/collection/__init__.py
Normal file
23
src/newsreader/news/collection/admin.py
Normal file
23
src/newsreader/news/collection/admin.py
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from newsreader.news.collection.models import CollectionRule
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionRuleAdmin(admin.ModelAdmin):
|
||||||
|
fields = (
|
||||||
|
"url",
|
||||||
|
"name",
|
||||||
|
"timezone",
|
||||||
|
"category",
|
||||||
|
)
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
"name",
|
||||||
|
"category",
|
||||||
|
"url",
|
||||||
|
"last_suceeded",
|
||||||
|
"succeeded",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(CollectionRule, CollectionRuleAdmin)
|
||||||
5
src/newsreader/news/collection/apps.py
Normal file
5
src/newsreader/news/collection/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionConfig(AppConfig):
|
||||||
|
name = 'collection'
|
||||||
81
src/newsreader/news/collection/base.py
Normal file
81
src/newsreader/news/collection/base.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from newsreader.news.collection.models import CollectionRule
|
||||||
|
|
||||||
|
|
||||||
|
class Builder:
|
||||||
|
instances = []
|
||||||
|
|
||||||
|
def __init__(self, stream):
|
||||||
|
self.stream = stream
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.create_posts(self.stream)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def create_posts(self, stream):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class Collector:
|
||||||
|
client = None
|
||||||
|
builder = None
|
||||||
|
|
||||||
|
def __init__(self, client=None, builder=None):
|
||||||
|
self.client = client if client else self.client
|
||||||
|
self.builder = builder if builder else self.builder
|
||||||
|
|
||||||
|
def collect(self, rules=None):
|
||||||
|
with self.client(rules=rules) as client:
|
||||||
|
for data, stream in client:
|
||||||
|
with self.builder((data, stream)) as builder:
|
||||||
|
builder.save()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class Stream:
|
||||||
|
def __init__(self, rule):
|
||||||
|
self.rule = rule
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
url = self.rule.url
|
||||||
|
response = requests.get(url)
|
||||||
|
return (self.parse(response.content), self)
|
||||||
|
|
||||||
|
def parse(self, payload):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class Client:
|
||||||
|
stream = Stream
|
||||||
|
|
||||||
|
def __init__(self, rules=None):
|
||||||
|
self.rules = rules if rules else CollectionRule.objects.all()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
for rule in self.rules:
|
||||||
|
stream = self.stream(rule)
|
||||||
|
|
||||||
|
yield stream.read()
|
||||||
|
|
||||||
|
def __exit__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
28
src/newsreader/news/collection/exceptions.py
Normal file
28
src/newsreader/news/collection/exceptions.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
class StreamException(Exception):
|
||||||
|
message = "Stream exception"
|
||||||
|
|
||||||
|
def __init__(self, message=None):
|
||||||
|
self.message = message if message else self.message
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.message
|
||||||
|
|
||||||
|
|
||||||
|
class StreamNotFoundException(StreamException):
|
||||||
|
message = "Stream not found"
|
||||||
|
|
||||||
|
|
||||||
|
class StreamDeniedException(StreamException):
|
||||||
|
message = "Stream does not have sufficient permissions"
|
||||||
|
|
||||||
|
|
||||||
|
class StreamTimeOutException(StreamException):
|
||||||
|
message = "Stream timed out"
|
||||||
|
|
||||||
|
|
||||||
|
class StreamForbiddenException(StreamException):
|
||||||
|
message = "Stream forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
class StreamParseException(StreamException):
|
||||||
|
message = "Stream could not be parsed"
|
||||||
211
src/newsreader/news/collection/feed.py
Normal file
211
src/newsreader/news/collection/feed.py
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
import bleach
|
||||||
|
import pytz
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from feedparser import parse
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from newsreader.news.collection.base import Builder, Client, Collector, Stream
|
||||||
|
from newsreader.news.collection.exceptions import (
|
||||||
|
StreamDeniedException,
|
||||||
|
StreamException,
|
||||||
|
StreamNotFoundException,
|
||||||
|
StreamParseException,
|
||||||
|
StreamTimeOutException,
|
||||||
|
)
|
||||||
|
from newsreader.news.collection.response_handler import ResponseHandler
|
||||||
|
from newsreader.news.collection.utils import build_publication_date
|
||||||
|
from newsreader.news.posts.models import Post
|
||||||
|
|
||||||
|
|
||||||
|
class FeedBuilder(Builder):
|
||||||
|
instances = []
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
_, stream = self.stream
|
||||||
|
self.instances = []
|
||||||
|
self.existing_posts = {
|
||||||
|
post.remote_identifier: post
|
||||||
|
for post in Post.objects.filter(rule=stream.rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
return super().__enter__()
|
||||||
|
|
||||||
|
def create_posts(self, stream):
|
||||||
|
data, stream = stream
|
||||||
|
entries = []
|
||||||
|
|
||||||
|
with FeedDuplicateHandler(stream.rule) as duplicate_handler:
|
||||||
|
try:
|
||||||
|
entries = data["entries"]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
instances = self.build(entries, stream.rule)
|
||||||
|
posts = duplicate_handler.check(instances)
|
||||||
|
|
||||||
|
self.instances = [post for post in posts]
|
||||||
|
|
||||||
|
def build(self, entries, rule):
|
||||||
|
field_mapping = {
|
||||||
|
"id": "remote_identifier",
|
||||||
|
"title": "title",
|
||||||
|
"summary": "body",
|
||||||
|
"link": "url",
|
||||||
|
"published_parsed": "publication_date",
|
||||||
|
"author": "author"
|
||||||
|
}
|
||||||
|
|
||||||
|
tz = pytz.timezone(rule.timezone)
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
data = {
|
||||||
|
"rule_id": rule.pk,
|
||||||
|
"category": rule.category
|
||||||
|
}
|
||||||
|
|
||||||
|
for field, value in field_mapping.items():
|
||||||
|
if field in entry:
|
||||||
|
if field == "published_parsed":
|
||||||
|
created, aware_datetime = build_publication_date(
|
||||||
|
entry[field], tz
|
||||||
|
)
|
||||||
|
data[value] = aware_datetime if created else None
|
||||||
|
elif field == "summary":
|
||||||
|
summary = self.sanitize_summary(entry[field])
|
||||||
|
data[value] = summary
|
||||||
|
else:
|
||||||
|
data[value] = entry[field]
|
||||||
|
|
||||||
|
yield Post(**data)
|
||||||
|
|
||||||
|
def sanitize_summary(self, summary):
|
||||||
|
attrs = {"a": ["href", "rel"], "img": ["alt", "src"],}
|
||||||
|
tags = ["a", "img", "p"]
|
||||||
|
|
||||||
|
return bleach.clean(summary, tags=tags, attributes=attrs) if summary else None
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
for post in self.instances:
|
||||||
|
post.save()
|
||||||
|
|
||||||
|
|
||||||
|
class FeedStream(Stream):
|
||||||
|
def read(self):
|
||||||
|
url = self.rule.url
|
||||||
|
response = requests.get(url)
|
||||||
|
|
||||||
|
with ResponseHandler(response) as response_handler:
|
||||||
|
response_handler.handle_response()
|
||||||
|
|
||||||
|
return (self.parse(response.content), self)
|
||||||
|
|
||||||
|
def parse(self, payload):
|
||||||
|
try:
|
||||||
|
return parse(payload)
|
||||||
|
except TypeError as e:
|
||||||
|
raise StreamParseException("Could not parse feed") from e
|
||||||
|
|
||||||
|
|
||||||
|
class FeedClient(Client):
|
||||||
|
stream = FeedStream
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
streams = [self.stream(rule) for rule in self.rules]
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
|
futures = {
|
||||||
|
executor.submit(stream.read): stream
|
||||||
|
for stream in streams
|
||||||
|
}
|
||||||
|
|
||||||
|
for future in as_completed(futures):
|
||||||
|
stream = futures[future]
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = future.result()
|
||||||
|
|
||||||
|
stream.rule.error = None
|
||||||
|
stream.rule.succeeded = True
|
||||||
|
stream.rule.last_suceeded = timezone.now()
|
||||||
|
|
||||||
|
yield response_data
|
||||||
|
except StreamException as e:
|
||||||
|
stream.rule.error = e.message
|
||||||
|
stream.rule.succeeded = False
|
||||||
|
|
||||||
|
yield ({"entries": []}, stream)
|
||||||
|
finally:
|
||||||
|
stream.rule.save()
|
||||||
|
|
||||||
|
|
||||||
|
class FeedCollector(Collector):
|
||||||
|
builder = FeedBuilder
|
||||||
|
client = FeedClient
|
||||||
|
|
||||||
|
|
||||||
|
class FeedDuplicateHandler:
|
||||||
|
def __init__(self, rule):
|
||||||
|
self.queryset = rule.post_set.all()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.existing_identifiers = self.queryset.filter(remote_identifier__isnull=False).values_list(
|
||||||
|
"remote_identifier", flat=True
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def check(self, instances):
|
||||||
|
for instance in instances:
|
||||||
|
if instance.remote_identifier in self.existing_identifiers:
|
||||||
|
existing_post = self.handle_duplicate(instance)
|
||||||
|
|
||||||
|
if existing_post:
|
||||||
|
yield existing_post
|
||||||
|
continue
|
||||||
|
elif not instance.remote_identifier and self.in_database(instance):
|
||||||
|
continue
|
||||||
|
|
||||||
|
yield instance
|
||||||
|
|
||||||
|
def in_database(self, entry):
|
||||||
|
values = {
|
||||||
|
"url": entry.url,
|
||||||
|
"title": entry.title,
|
||||||
|
"body": entry.body,
|
||||||
|
"publication_date": entry.publication_date
|
||||||
|
}
|
||||||
|
|
||||||
|
for existing_entry in self.queryset.order_by("-publication_date")[:50]:
|
||||||
|
if self.is_duplicate(existing_entry, values):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def is_duplicate(self, existing_entry, values):
|
||||||
|
for key, value in values.items():
|
||||||
|
existing_value = getattr(existing_entry, key, object())
|
||||||
|
if existing_value != value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def handle_duplicate(self, instance):
|
||||||
|
try:
|
||||||
|
existing_instance = self.queryset.get(
|
||||||
|
remote_identifier=instance.remote_identifier,
|
||||||
|
)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
for field in instance._meta.get_fields():
|
||||||
|
getattr(existing_instance, field.name, object())
|
||||||
|
new_value = getattr(instance, field.name, object())
|
||||||
|
|
||||||
|
if new_value and field.name != "id":
|
||||||
|
setattr(existing_instance, field.name, new_value)
|
||||||
|
|
||||||
|
return existing_instance
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from newsreader.news.collection.feed import FeedCollector
|
||||||
|
from newsreader.news.collection.models import CollectionRule
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Collects Atom/RSS feeds'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
CollectionRule.objects.all()
|
||||||
|
|
||||||
|
collector = FeedCollector()
|
||||||
|
collector.collect()
|
||||||
31
src/newsreader/news/collection/migrations/0001_initial.py
Normal file
31
src/newsreader/news/collection/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Generated by Django 2.2 on 2019-04-10 20:10
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CollectionRule',
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
'id',
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name='ID'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('url', models.URLField()),
|
||||||
|
('last_suceeded', models.DateTimeField()),
|
||||||
|
('succeeded', models.BooleanField(default=False)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 2.2 on 2019-04-10 20:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('collection', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='collectionrule',
|
||||||
|
name='last_suceeded',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 2.2 on 2019-05-20 20:06
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('posts', '0002_auto_20190520_2206'),
|
||||||
|
('collection', '0002_auto_20190410_2028'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='collectionrule',
|
||||||
|
name='category',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text='Posts from this rule will be tagged with this category',
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to='posts.Category',
|
||||||
|
verbose_name='Category'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,517 @@
|
||||||
|
# Generated by Django 2.2 on 2019-05-20 20:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('collection', '0003_collectionrule_category'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='collectionrule',
|
||||||
|
name='timezone',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('Africa/Abidjan', 'Africa/Abidjan'),
|
||||||
|
('Africa/Accra', 'Africa/Accra'),
|
||||||
|
('Africa/Addis_Ababa', 'Africa/Addis_Ababa'),
|
||||||
|
('Africa/Algiers', 'Africa/Algiers'),
|
||||||
|
('Africa/Asmara',
|
||||||
|
'Africa/Asmara'), ('Africa/Asmera', 'Africa/Asmera'),
|
||||||
|
('Africa/Bamako',
|
||||||
|
'Africa/Bamako'), ('Africa/Bangui', 'Africa/Bangui'),
|
||||||
|
('Africa/Banjul', 'Africa/Banjul'),
|
||||||
|
('Africa/Bissau', 'Africa/Bissau'),
|
||||||
|
('Africa/Blantyre', 'Africa/Blantyre'),
|
||||||
|
('Africa/Brazzaville', 'Africa/Brazzaville'),
|
||||||
|
('Africa/Bujumbura', 'Africa/Bujumbura'),
|
||||||
|
('Africa/Cairo', 'Africa/Cairo'),
|
||||||
|
('Africa/Casablanca', 'Africa/Casablanca'),
|
||||||
|
('Africa/Ceuta',
|
||||||
|
'Africa/Ceuta'), ('Africa/Conakry', 'Africa/Conakry'),
|
||||||
|
('Africa/Dakar', 'Africa/Dakar'),
|
||||||
|
('Africa/Dar_es_Salaam', 'Africa/Dar_es_Salaam'),
|
||||||
|
('Africa/Djibouti', 'Africa/Djibouti'),
|
||||||
|
('Africa/Douala', 'Africa/Douala'),
|
||||||
|
('Africa/El_Aaiun', 'Africa/El_Aaiun'),
|
||||||
|
('Africa/Freetown', 'Africa/Freetown'),
|
||||||
|
('Africa/Gaborone', 'Africa/Gaborone'),
|
||||||
|
('Africa/Harare', 'Africa/Harare'),
|
||||||
|
('Africa/Johannesburg', 'Africa/Johannesburg'),
|
||||||
|
('Africa/Juba', 'Africa/Juba'), ('Africa/Kampala', 'Africa/Kampala'),
|
||||||
|
('Africa/Khartoum', 'Africa/Khartoum'),
|
||||||
|
('Africa/Kigali', 'Africa/Kigali'),
|
||||||
|
('Africa/Kinshasa', 'Africa/Kinshasa'),
|
||||||
|
('Africa/Lagos', 'Africa/Lagos'),
|
||||||
|
('Africa/Libreville', 'Africa/Libreville'),
|
||||||
|
('Africa/Lome', 'Africa/Lome'), ('Africa/Luanda', 'Africa/Luanda'),
|
||||||
|
('Africa/Lubumbashi', 'Africa/Lubumbashi'),
|
||||||
|
('Africa/Lusaka',
|
||||||
|
'Africa/Lusaka'), ('Africa/Malabo', 'Africa/Malabo'),
|
||||||
|
('Africa/Maputo', 'Africa/Maputo'),
|
||||||
|
('Africa/Maseru', 'Africa/Maseru'),
|
||||||
|
('Africa/Mbabane', 'Africa/Mbabane'),
|
||||||
|
('Africa/Mogadishu', 'Africa/Mogadishu'),
|
||||||
|
('Africa/Monrovia', 'Africa/Monrovia'),
|
||||||
|
('Africa/Nairobi', 'Africa/Nairobi'),
|
||||||
|
('Africa/Ndjamena', 'Africa/Ndjamena'),
|
||||||
|
('Africa/Niamey', 'Africa/Niamey'),
|
||||||
|
('Africa/Nouakchott', 'Africa/Nouakchott'),
|
||||||
|
('Africa/Ouagadougou', 'Africa/Ouagadougou'),
|
||||||
|
('Africa/Porto-Novo', 'Africa/Porto-Novo'),
|
||||||
|
('Africa/Sao_Tome', 'Africa/Sao_Tome'),
|
||||||
|
('Africa/Timbuktu', 'Africa/Timbuktu'),
|
||||||
|
('Africa/Tripoli', 'Africa/Tripoli'),
|
||||||
|
('Africa/Tunis', 'Africa/Tunis'),
|
||||||
|
('Africa/Windhoek', 'Africa/Windhoek'),
|
||||||
|
('America/Adak', 'America/Adak'),
|
||||||
|
('America/Anchorage', 'America/Anchorage'),
|
||||||
|
('America/Anguilla', 'America/Anguilla'),
|
||||||
|
('America/Antigua', 'America/Antigua'),
|
||||||
|
('America/Araguaina', 'America/Araguaina'),
|
||||||
|
('America/Argentina/Buenos_Aires', 'America/Argentina/Buenos_Aires'),
|
||||||
|
('America/Argentina/Catamarca', 'America/Argentina/Catamarca'),
|
||||||
|
(
|
||||||
|
'America/Argentina/ComodRivadavia',
|
||||||
|
'America/Argentina/ComodRivadavia'
|
||||||
|
), ('America/Argentina/Cordoba', 'America/Argentina/Cordoba'),
|
||||||
|
('America/Argentina/Jujuy', 'America/Argentina/Jujuy'),
|
||||||
|
('America/Argentina/La_Rioja', 'America/Argentina/La_Rioja'),
|
||||||
|
('America/Argentina/Mendoza', 'America/Argentina/Mendoza'),
|
||||||
|
('America/Argentina/Rio_Gallegos', 'America/Argentina/Rio_Gallegos'),
|
||||||
|
('America/Argentina/Salta', 'America/Argentina/Salta'),
|
||||||
|
('America/Argentina/San_Juan', 'America/Argentina/San_Juan'),
|
||||||
|
('America/Argentina/San_Luis', 'America/Argentina/San_Luis'),
|
||||||
|
('America/Argentina/Tucuman', 'America/Argentina/Tucuman'),
|
||||||
|
('America/Argentina/Ushuaia', 'America/Argentina/Ushuaia'),
|
||||||
|
('America/Aruba', 'America/Aruba'),
|
||||||
|
('America/Asuncion', 'America/Asuncion'),
|
||||||
|
('America/Atikokan', 'America/Atikokan'),
|
||||||
|
('America/Atka', 'America/Atka'), ('America/Bahia', 'America/Bahia'),
|
||||||
|
('America/Bahia_Banderas', 'America/Bahia_Banderas'),
|
||||||
|
('America/Barbados', 'America/Barbados'),
|
||||||
|
('America/Belem', 'America/Belem'),
|
||||||
|
('America/Belize', 'America/Belize'),
|
||||||
|
('America/Blanc-Sablon', 'America/Blanc-Sablon'),
|
||||||
|
('America/Boa_Vista', 'America/Boa_Vista'),
|
||||||
|
('America/Bogota', 'America/Bogota'),
|
||||||
|
('America/Boise', 'America/Boise'),
|
||||||
|
('America/Buenos_Aires', 'America/Buenos_Aires'),
|
||||||
|
('America/Cambridge_Bay', 'America/Cambridge_Bay'),
|
||||||
|
('America/Campo_Grande', 'America/Campo_Grande'),
|
||||||
|
('America/Cancun', 'America/Cancun'),
|
||||||
|
('America/Caracas', 'America/Caracas'),
|
||||||
|
('America/Catamarca', 'America/Catamarca'),
|
||||||
|
('America/Cayenne', 'America/Cayenne'),
|
||||||
|
('America/Cayman', 'America/Cayman'),
|
||||||
|
('America/Chicago', 'America/Chicago'),
|
||||||
|
('America/Chihuahua', 'America/Chihuahua'),
|
||||||
|
('America/Coral_Harbour', 'America/Coral_Harbour'),
|
||||||
|
('America/Cordoba', 'America/Cordoba'),
|
||||||
|
('America/Costa_Rica', 'America/Costa_Rica'),
|
||||||
|
('America/Creston', 'America/Creston'),
|
||||||
|
('America/Cuiaba', 'America/Cuiaba'),
|
||||||
|
('America/Curacao', 'America/Curacao'),
|
||||||
|
('America/Danmarkshavn', 'America/Danmarkshavn'),
|
||||||
|
('America/Dawson', 'America/Dawson'),
|
||||||
|
('America/Dawson_Creek', 'America/Dawson_Creek'),
|
||||||
|
('America/Denver', 'America/Denver'),
|
||||||
|
('America/Detroit', 'America/Detroit'),
|
||||||
|
('America/Dominica', 'America/Dominica'),
|
||||||
|
('America/Edmonton', 'America/Edmonton'),
|
||||||
|
('America/Eirunepe', 'America/Eirunepe'),
|
||||||
|
('America/El_Salvador', 'America/El_Salvador'),
|
||||||
|
('America/Ensenada', 'America/Ensenada'),
|
||||||
|
('America/Fort_Nelson', 'America/Fort_Nelson'),
|
||||||
|
('America/Fort_Wayne', 'America/Fort_Wayne'),
|
||||||
|
('America/Fortaleza', 'America/Fortaleza'),
|
||||||
|
('America/Glace_Bay', 'America/Glace_Bay'),
|
||||||
|
('America/Godthab', 'America/Godthab'),
|
||||||
|
('America/Goose_Bay', 'America/Goose_Bay'),
|
||||||
|
('America/Grand_Turk', 'America/Grand_Turk'),
|
||||||
|
('America/Grenada', 'America/Grenada'),
|
||||||
|
('America/Guadeloupe', 'America/Guadeloupe'),
|
||||||
|
('America/Guatemala', 'America/Guatemala'),
|
||||||
|
('America/Guayaquil', 'America/Guayaquil'),
|
||||||
|
('America/Guyana', 'America/Guyana'),
|
||||||
|
('America/Halifax', 'America/Halifax'),
|
||||||
|
('America/Havana', 'America/Havana'),
|
||||||
|
('America/Hermosillo', 'America/Hermosillo'),
|
||||||
|
('America/Indiana/Indianapolis', 'America/Indiana/Indianapolis'),
|
||||||
|
('America/Indiana/Knox', 'America/Indiana/Knox'),
|
||||||
|
('America/Indiana/Marengo', 'America/Indiana/Marengo'),
|
||||||
|
('America/Indiana/Petersburg', 'America/Indiana/Petersburg'),
|
||||||
|
('America/Indiana/Tell_City', 'America/Indiana/Tell_City'),
|
||||||
|
('America/Indiana/Vevay', 'America/Indiana/Vevay'),
|
||||||
|
('America/Indiana/Vincennes', 'America/Indiana/Vincennes'),
|
||||||
|
('America/Indiana/Winamac', 'America/Indiana/Winamac'),
|
||||||
|
('America/Indianapolis', 'America/Indianapolis'),
|
||||||
|
('America/Inuvik', 'America/Inuvik'),
|
||||||
|
('America/Iqaluit', 'America/Iqaluit'),
|
||||||
|
('America/Jamaica', 'America/Jamaica'),
|
||||||
|
('America/Jujuy', 'America/Jujuy'),
|
||||||
|
('America/Juneau', 'America/Juneau'),
|
||||||
|
('America/Kentucky/Louisville', 'America/Kentucky/Louisville'),
|
||||||
|
('America/Kentucky/Monticello', 'America/Kentucky/Monticello'),
|
||||||
|
('America/Knox_IN', 'America/Knox_IN'),
|
||||||
|
('America/Kralendijk', 'America/Kralendijk'),
|
||||||
|
('America/La_Paz',
|
||||||
|
'America/La_Paz'), ('America/Lima', 'America/Lima'),
|
||||||
|
('America/Los_Angeles', 'America/Los_Angeles'),
|
||||||
|
('America/Louisville', 'America/Louisville'),
|
||||||
|
('America/Lower_Princes', 'America/Lower_Princes'),
|
||||||
|
('America/Maceio', 'America/Maceio'),
|
||||||
|
('America/Managua', 'America/Managua'),
|
||||||
|
('America/Manaus', 'America/Manaus'),
|
||||||
|
('America/Marigot', 'America/Marigot'),
|
||||||
|
('America/Martinique', 'America/Martinique'),
|
||||||
|
('America/Matamoros', 'America/Matamoros'),
|
||||||
|
('America/Mazatlan', 'America/Mazatlan'),
|
||||||
|
('America/Mendoza', 'America/Mendoza'),
|
||||||
|
('America/Menominee', 'America/Menominee'),
|
||||||
|
('America/Merida', 'America/Merida'),
|
||||||
|
('America/Metlakatla', 'America/Metlakatla'),
|
||||||
|
('America/Mexico_City', 'America/Mexico_City'),
|
||||||
|
('America/Miquelon', 'America/Miquelon'),
|
||||||
|
('America/Moncton', 'America/Moncton'),
|
||||||
|
('America/Monterrey', 'America/Monterrey'),
|
||||||
|
('America/Montevideo', 'America/Montevideo'),
|
||||||
|
('America/Montreal', 'America/Montreal'),
|
||||||
|
('America/Montserrat', 'America/Montserrat'),
|
||||||
|
('America/Nassau', 'America/Nassau'),
|
||||||
|
('America/New_York', 'America/New_York'),
|
||||||
|
('America/Nipigon', 'America/Nipigon'),
|
||||||
|
('America/Nome', 'America/Nome'),
|
||||||
|
('America/Noronha', 'America/Noronha'),
|
||||||
|
('America/North_Dakota/Beulah', 'America/North_Dakota/Beulah'),
|
||||||
|
('America/North_Dakota/Center', 'America/North_Dakota/Center'),
|
||||||
|
('America/North_Dakota/New_Salem', 'America/North_Dakota/New_Salem'),
|
||||||
|
('America/Ojinaga', 'America/Ojinaga'),
|
||||||
|
('America/Panama', 'America/Panama'),
|
||||||
|
('America/Pangnirtung', 'America/Pangnirtung'),
|
||||||
|
('America/Paramaribo', 'America/Paramaribo'),
|
||||||
|
('America/Phoenix', 'America/Phoenix'),
|
||||||
|
('America/Port-au-Prince', 'America/Port-au-Prince'),
|
||||||
|
('America/Port_of_Spain', 'America/Port_of_Spain'),
|
||||||
|
('America/Porto_Acre', 'America/Porto_Acre'),
|
||||||
|
('America/Porto_Velho', 'America/Porto_Velho'),
|
||||||
|
('America/Puerto_Rico', 'America/Puerto_Rico'),
|
||||||
|
('America/Punta_Arenas', 'America/Punta_Arenas'),
|
||||||
|
('America/Rainy_River', 'America/Rainy_River'),
|
||||||
|
('America/Rankin_Inlet', 'America/Rankin_Inlet'),
|
||||||
|
('America/Recife', 'America/Recife'),
|
||||||
|
('America/Regina', 'America/Regina'),
|
||||||
|
('America/Resolute', 'America/Resolute'),
|
||||||
|
('America/Rio_Branco', 'America/Rio_Branco'),
|
||||||
|
('America/Rosario', 'America/Rosario'),
|
||||||
|
('America/Santa_Isabel', 'America/Santa_Isabel'),
|
||||||
|
('America/Santarem', 'America/Santarem'),
|
||||||
|
('America/Santiago', 'America/Santiago'),
|
||||||
|
('America/Santo_Domingo', 'America/Santo_Domingo'),
|
||||||
|
('America/Sao_Paulo', 'America/Sao_Paulo'),
|
||||||
|
('America/Scoresbysund', 'America/Scoresbysund'),
|
||||||
|
('America/Shiprock', 'America/Shiprock'),
|
||||||
|
('America/Sitka', 'America/Sitka'),
|
||||||
|
('America/St_Barthelemy', 'America/St_Barthelemy'),
|
||||||
|
('America/St_Johns', 'America/St_Johns'),
|
||||||
|
('America/St_Kitts', 'America/St_Kitts'),
|
||||||
|
('America/St_Lucia', 'America/St_Lucia'),
|
||||||
|
('America/St_Thomas', 'America/St_Thomas'),
|
||||||
|
('America/St_Vincent', 'America/St_Vincent'),
|
||||||
|
('America/Swift_Current', 'America/Swift_Current'),
|
||||||
|
('America/Tegucigalpa', 'America/Tegucigalpa'),
|
||||||
|
('America/Thule', 'America/Thule'),
|
||||||
|
('America/Thunder_Bay', 'America/Thunder_Bay'),
|
||||||
|
('America/Tijuana', 'America/Tijuana'),
|
||||||
|
('America/Toronto', 'America/Toronto'),
|
||||||
|
('America/Tortola', 'America/Tortola'),
|
||||||
|
('America/Vancouver', 'America/Vancouver'),
|
||||||
|
('America/Virgin', 'America/Virgin'),
|
||||||
|
('America/Whitehorse', 'America/Whitehorse'),
|
||||||
|
('America/Winnipeg', 'America/Winnipeg'),
|
||||||
|
('America/Yakutat', 'America/Yakutat'),
|
||||||
|
('America/Yellowknife', 'America/Yellowknife'),
|
||||||
|
('Antarctica/Casey', 'Antarctica/Casey'),
|
||||||
|
('Antarctica/Davis', 'Antarctica/Davis'),
|
||||||
|
('Antarctica/DumontDUrville', 'Antarctica/DumontDUrville'),
|
||||||
|
('Antarctica/Macquarie', 'Antarctica/Macquarie'),
|
||||||
|
('Antarctica/Mawson', 'Antarctica/Mawson'),
|
||||||
|
('Antarctica/McMurdo', 'Antarctica/McMurdo'),
|
||||||
|
('Antarctica/Palmer', 'Antarctica/Palmer'),
|
||||||
|
('Antarctica/Rothera', 'Antarctica/Rothera'),
|
||||||
|
('Antarctica/South_Pole', 'Antarctica/South_Pole'),
|
||||||
|
('Antarctica/Syowa', 'Antarctica/Syowa'),
|
||||||
|
('Antarctica/Troll', 'Antarctica/Troll'),
|
||||||
|
('Antarctica/Vostok', 'Antarctica/Vostok'),
|
||||||
|
('Arctic/Longyearbyen', 'Arctic/Longyearbyen'),
|
||||||
|
('Asia/Aden', 'Asia/Aden'), ('Asia/Almaty', 'Asia/Almaty'),
|
||||||
|
('Asia/Amman', 'Asia/Amman'), ('Asia/Anadyr', 'Asia/Anadyr'),
|
||||||
|
('Asia/Aqtau', 'Asia/Aqtau'), ('Asia/Aqtobe', 'Asia/Aqtobe'),
|
||||||
|
('Asia/Ashgabat', 'Asia/Ashgabat'),
|
||||||
|
('Asia/Ashkhabad', 'Asia/Ashkhabad'), ('Asia/Atyrau', 'Asia/Atyrau'),
|
||||||
|
('Asia/Baghdad', 'Asia/Baghdad'), ('Asia/Bahrain', 'Asia/Bahrain'),
|
||||||
|
('Asia/Baku', 'Asia/Baku'), ('Asia/Bangkok', 'Asia/Bangkok'),
|
||||||
|
('Asia/Barnaul', 'Asia/Barnaul'), ('Asia/Beirut', 'Asia/Beirut'),
|
||||||
|
('Asia/Bishkek', 'Asia/Bishkek'), ('Asia/Brunei', 'Asia/Brunei'),
|
||||||
|
('Asia/Calcutta', 'Asia/Calcutta'), ('Asia/Chita', 'Asia/Chita'),
|
||||||
|
('Asia/Choibalsan', 'Asia/Choibalsan'),
|
||||||
|
('Asia/Chongqing', 'Asia/Chongqing'),
|
||||||
|
('Asia/Chungking',
|
||||||
|
'Asia/Chungking'), ('Asia/Colombo', 'Asia/Colombo'),
|
||||||
|
('Asia/Dacca', 'Asia/Dacca'), ('Asia/Damascus', 'Asia/Damascus'),
|
||||||
|
('Asia/Dhaka', 'Asia/Dhaka'), ('Asia/Dili', 'Asia/Dili'),
|
||||||
|
('Asia/Dubai', 'Asia/Dubai'), ('Asia/Dushanbe', 'Asia/Dushanbe'),
|
||||||
|
('Asia/Famagusta', 'Asia/Famagusta'), ('Asia/Gaza', 'Asia/Gaza'),
|
||||||
|
('Asia/Harbin', 'Asia/Harbin'), ('Asia/Hebron', 'Asia/Hebron'),
|
||||||
|
('Asia/Ho_Chi_Minh', 'Asia/Ho_Chi_Minh'),
|
||||||
|
('Asia/Hong_Kong', 'Asia/Hong_Kong'), ('Asia/Hovd', 'Asia/Hovd'),
|
||||||
|
('Asia/Irkutsk', 'Asia/Irkutsk'), ('Asia/Istanbul', 'Asia/Istanbul'),
|
||||||
|
('Asia/Jakarta', 'Asia/Jakarta'), ('Asia/Jayapura', 'Asia/Jayapura'),
|
||||||
|
('Asia/Jerusalem', 'Asia/Jerusalem'), ('Asia/Kabul', 'Asia/Kabul'),
|
||||||
|
('Asia/Kamchatka', 'Asia/Kamchatka'),
|
||||||
|
('Asia/Karachi', 'Asia/Karachi'), ('Asia/Kashgar', 'Asia/Kashgar'),
|
||||||
|
('Asia/Kathmandu', 'Asia/Kathmandu'),
|
||||||
|
('Asia/Katmandu',
|
||||||
|
'Asia/Katmandu'), ('Asia/Khandyga', 'Asia/Khandyga'),
|
||||||
|
('Asia/Kolkata', 'Asia/Kolkata'),
|
||||||
|
('Asia/Krasnoyarsk', 'Asia/Krasnoyarsk'),
|
||||||
|
('Asia/Kuala_Lumpur', 'Asia/Kuala_Lumpur'),
|
||||||
|
('Asia/Kuching', 'Asia/Kuching'), ('Asia/Kuwait', 'Asia/Kuwait'),
|
||||||
|
('Asia/Macao', 'Asia/Macao'), ('Asia/Macau', 'Asia/Macau'),
|
||||||
|
('Asia/Magadan', 'Asia/Magadan'), ('Asia/Makassar', 'Asia/Makassar'),
|
||||||
|
('Asia/Manila', 'Asia/Manila'), ('Asia/Muscat', 'Asia/Muscat'),
|
||||||
|
('Asia/Nicosia', 'Asia/Nicosia'),
|
||||||
|
('Asia/Novokuznetsk', 'Asia/Novokuznetsk'),
|
||||||
|
('Asia/Novosibirsk', 'Asia/Novosibirsk'), ('Asia/Omsk', 'Asia/Omsk'),
|
||||||
|
('Asia/Oral', 'Asia/Oral'), ('Asia/Phnom_Penh', 'Asia/Phnom_Penh'),
|
||||||
|
('Asia/Pontianak', 'Asia/Pontianak'),
|
||||||
|
('Asia/Pyongyang', 'Asia/Pyongyang'), ('Asia/Qatar', 'Asia/Qatar'),
|
||||||
|
('Asia/Qostanay', 'Asia/Qostanay'),
|
||||||
|
('Asia/Qyzylorda',
|
||||||
|
'Asia/Qyzylorda'), ('Asia/Rangoon', 'Asia/Rangoon'),
|
||||||
|
('Asia/Riyadh', 'Asia/Riyadh'), ('Asia/Saigon', 'Asia/Saigon'),
|
||||||
|
('Asia/Sakhalin', 'Asia/Sakhalin'),
|
||||||
|
('Asia/Samarkand', 'Asia/Samarkand'), ('Asia/Seoul', 'Asia/Seoul'),
|
||||||
|
('Asia/Shanghai', 'Asia/Shanghai'),
|
||||||
|
('Asia/Singapore', 'Asia/Singapore'),
|
||||||
|
('Asia/Srednekolymsk', 'Asia/Srednekolymsk'),
|
||||||
|
('Asia/Taipei', 'Asia/Taipei'), ('Asia/Tashkent', 'Asia/Tashkent'),
|
||||||
|
('Asia/Tbilisi', 'Asia/Tbilisi'), ('Asia/Tehran', 'Asia/Tehran'),
|
||||||
|
('Asia/Tel_Aviv', 'Asia/Tel_Aviv'), ('Asia/Thimbu', 'Asia/Thimbu'),
|
||||||
|
('Asia/Thimphu', 'Asia/Thimphu'), ('Asia/Tokyo', 'Asia/Tokyo'),
|
||||||
|
('Asia/Tomsk', 'Asia/Tomsk'),
|
||||||
|
('Asia/Ujung_Pandang', 'Asia/Ujung_Pandang'),
|
||||||
|
('Asia/Ulaanbaatar', 'Asia/Ulaanbaatar'),
|
||||||
|
('Asia/Ulan_Bator',
|
||||||
|
'Asia/Ulan_Bator'), ('Asia/Urumqi', 'Asia/Urumqi'),
|
||||||
|
('Asia/Ust-Nera', 'Asia/Ust-Nera'),
|
||||||
|
('Asia/Vientiane', 'Asia/Vientiane'),
|
||||||
|
('Asia/Vladivostok', 'Asia/Vladivostok'),
|
||||||
|
('Asia/Yakutsk', 'Asia/Yakutsk'), ('Asia/Yangon', 'Asia/Yangon'),
|
||||||
|
('Asia/Yekaterinburg', 'Asia/Yekaterinburg'),
|
||||||
|
('Asia/Yerevan', 'Asia/Yerevan'),
|
||||||
|
('Atlantic/Azores', 'Atlantic/Azores'),
|
||||||
|
('Atlantic/Bermuda', 'Atlantic/Bermuda'),
|
||||||
|
('Atlantic/Canary', 'Atlantic/Canary'),
|
||||||
|
('Atlantic/Cape_Verde', 'Atlantic/Cape_Verde'),
|
||||||
|
('Atlantic/Faeroe', 'Atlantic/Faeroe'),
|
||||||
|
('Atlantic/Faroe', 'Atlantic/Faroe'),
|
||||||
|
('Atlantic/Jan_Mayen', 'Atlantic/Jan_Mayen'),
|
||||||
|
('Atlantic/Madeira', 'Atlantic/Madeira'),
|
||||||
|
('Atlantic/Reykjavik', 'Atlantic/Reykjavik'),
|
||||||
|
('Atlantic/South_Georgia', 'Atlantic/South_Georgia'),
|
||||||
|
('Atlantic/St_Helena', 'Atlantic/St_Helena'),
|
||||||
|
('Atlantic/Stanley', 'Atlantic/Stanley'),
|
||||||
|
('Australia/ACT', 'Australia/ACT'),
|
||||||
|
('Australia/Adelaide', 'Australia/Adelaide'),
|
||||||
|
('Australia/Brisbane', 'Australia/Brisbane'),
|
||||||
|
('Australia/Broken_Hill', 'Australia/Broken_Hill'),
|
||||||
|
('Australia/Canberra', 'Australia/Canberra'),
|
||||||
|
('Australia/Currie', 'Australia/Currie'),
|
||||||
|
('Australia/Darwin', 'Australia/Darwin'),
|
||||||
|
('Australia/Eucla', 'Australia/Eucla'),
|
||||||
|
('Australia/Hobart', 'Australia/Hobart'),
|
||||||
|
('Australia/LHI', 'Australia/LHI'),
|
||||||
|
('Australia/Lindeman', 'Australia/Lindeman'),
|
||||||
|
('Australia/Lord_Howe', 'Australia/Lord_Howe'),
|
||||||
|
('Australia/Melbourne', 'Australia/Melbourne'),
|
||||||
|
('Australia/NSW', 'Australia/NSW'),
|
||||||
|
('Australia/North', 'Australia/North'),
|
||||||
|
('Australia/Perth', 'Australia/Perth'),
|
||||||
|
('Australia/Queensland', 'Australia/Queensland'),
|
||||||
|
('Australia/South', 'Australia/South'),
|
||||||
|
('Australia/Sydney', 'Australia/Sydney'),
|
||||||
|
('Australia/Tasmania', 'Australia/Tasmania'),
|
||||||
|
('Australia/Victoria', 'Australia/Victoria'),
|
||||||
|
('Australia/West', 'Australia/West'),
|
||||||
|
('Australia/Yancowinna', 'Australia/Yancowinna'),
|
||||||
|
('Brazil/Acre', 'Brazil/Acre'),
|
||||||
|
('Brazil/DeNoronha', 'Brazil/DeNoronha'),
|
||||||
|
('Brazil/East', 'Brazil/East'), ('Brazil/West', 'Brazil/West'),
|
||||||
|
('CET', 'CET'), ('CST6CDT', 'CST6CDT'),
|
||||||
|
('Canada/Atlantic', 'Canada/Atlantic'),
|
||||||
|
('Canada/Central', 'Canada/Central'),
|
||||||
|
('Canada/Eastern', 'Canada/Eastern'),
|
||||||
|
('Canada/Mountain', 'Canada/Mountain'),
|
||||||
|
('Canada/Newfoundland', 'Canada/Newfoundland'),
|
||||||
|
('Canada/Pacific', 'Canada/Pacific'),
|
||||||
|
('Canada/Saskatchewan', 'Canada/Saskatchewan'),
|
||||||
|
('Canada/Yukon', 'Canada/Yukon'),
|
||||||
|
('Chile/Continental', 'Chile/Continental'),
|
||||||
|
('Chile/EasterIsland', 'Chile/EasterIsland'), ('Cuba', 'Cuba'),
|
||||||
|
('EET', 'EET'), ('EST', 'EST'), ('EST5EDT', 'EST5EDT'),
|
||||||
|
('Egypt', 'Egypt'), ('Eire', 'Eire'), ('Etc/GMT', 'Etc/GMT'),
|
||||||
|
('Etc/GMT+0', 'Etc/GMT+0'), ('Etc/GMT+1', 'Etc/GMT+1'),
|
||||||
|
('Etc/GMT+10', 'Etc/GMT+10'), ('Etc/GMT+11', 'Etc/GMT+11'),
|
||||||
|
('Etc/GMT+12', 'Etc/GMT+12'), ('Etc/GMT+2', 'Etc/GMT+2'),
|
||||||
|
('Etc/GMT+3', 'Etc/GMT+3'), ('Etc/GMT+4', 'Etc/GMT+4'),
|
||||||
|
('Etc/GMT+5', 'Etc/GMT+5'), ('Etc/GMT+6', 'Etc/GMT+6'),
|
||||||
|
('Etc/GMT+7', 'Etc/GMT+7'), ('Etc/GMT+8', 'Etc/GMT+8'),
|
||||||
|
('Etc/GMT+9', 'Etc/GMT+9'), ('Etc/GMT-0', 'Etc/GMT-0'),
|
||||||
|
('Etc/GMT-1', 'Etc/GMT-1'), ('Etc/GMT-10', 'Etc/GMT-10'),
|
||||||
|
('Etc/GMT-11', 'Etc/GMT-11'), ('Etc/GMT-12', 'Etc/GMT-12'),
|
||||||
|
('Etc/GMT-13', 'Etc/GMT-13'), ('Etc/GMT-14', 'Etc/GMT-14'),
|
||||||
|
('Etc/GMT-2', 'Etc/GMT-2'), ('Etc/GMT-3', 'Etc/GMT-3'),
|
||||||
|
('Etc/GMT-4', 'Etc/GMT-4'), ('Etc/GMT-5', 'Etc/GMT-5'),
|
||||||
|
('Etc/GMT-6', 'Etc/GMT-6'), ('Etc/GMT-7', 'Etc/GMT-7'),
|
||||||
|
('Etc/GMT-8', 'Etc/GMT-8'), ('Etc/GMT-9', 'Etc/GMT-9'),
|
||||||
|
('Etc/GMT0', 'Etc/GMT0'), ('Etc/Greenwich', 'Etc/Greenwich'),
|
||||||
|
('Etc/UCT', 'Etc/UCT'), ('Etc/UTC', 'Etc/UTC'),
|
||||||
|
('Etc/Universal', 'Etc/Universal'), ('Etc/Zulu', 'Etc/Zulu'),
|
||||||
|
('Europe/Amsterdam', 'Europe/Amsterdam'),
|
||||||
|
('Europe/Andorra', 'Europe/Andorra'),
|
||||||
|
('Europe/Astrakhan', 'Europe/Astrakhan'),
|
||||||
|
('Europe/Athens', 'Europe/Athens'),
|
||||||
|
('Europe/Belfast', 'Europe/Belfast'),
|
||||||
|
('Europe/Belgrade', 'Europe/Belgrade'),
|
||||||
|
('Europe/Berlin', 'Europe/Berlin'),
|
||||||
|
('Europe/Bratislava', 'Europe/Bratislava'),
|
||||||
|
('Europe/Brussels', 'Europe/Brussels'),
|
||||||
|
('Europe/Bucharest', 'Europe/Bucharest'),
|
||||||
|
('Europe/Budapest', 'Europe/Budapest'),
|
||||||
|
('Europe/Busingen', 'Europe/Busingen'),
|
||||||
|
('Europe/Chisinau', 'Europe/Chisinau'),
|
||||||
|
('Europe/Copenhagen', 'Europe/Copenhagen'),
|
||||||
|
('Europe/Dublin', 'Europe/Dublin'),
|
||||||
|
('Europe/Gibraltar', 'Europe/Gibraltar'),
|
||||||
|
('Europe/Guernsey', 'Europe/Guernsey'),
|
||||||
|
('Europe/Helsinki', 'Europe/Helsinki'),
|
||||||
|
('Europe/Isle_of_Man', 'Europe/Isle_of_Man'),
|
||||||
|
('Europe/Istanbul', 'Europe/Istanbul'),
|
||||||
|
('Europe/Jersey', 'Europe/Jersey'),
|
||||||
|
('Europe/Kaliningrad', 'Europe/Kaliningrad'),
|
||||||
|
('Europe/Kiev', 'Europe/Kiev'), ('Europe/Kirov', 'Europe/Kirov'),
|
||||||
|
('Europe/Lisbon', 'Europe/Lisbon'),
|
||||||
|
('Europe/Ljubljana', 'Europe/Ljubljana'),
|
||||||
|
('Europe/London', 'Europe/London'),
|
||||||
|
('Europe/Luxembourg', 'Europe/Luxembourg'),
|
||||||
|
('Europe/Madrid', 'Europe/Madrid'), ('Europe/Malta', 'Europe/Malta'),
|
||||||
|
('Europe/Mariehamn', 'Europe/Mariehamn'),
|
||||||
|
('Europe/Minsk', 'Europe/Minsk'), ('Europe/Monaco', 'Europe/Monaco'),
|
||||||
|
('Europe/Moscow', 'Europe/Moscow'),
|
||||||
|
('Europe/Nicosia', 'Europe/Nicosia'), ('Europe/Oslo', 'Europe/Oslo'),
|
||||||
|
('Europe/Paris', 'Europe/Paris'),
|
||||||
|
('Europe/Podgorica', 'Europe/Podgorica'),
|
||||||
|
('Europe/Prague', 'Europe/Prague'), ('Europe/Riga', 'Europe/Riga'),
|
||||||
|
('Europe/Rome', 'Europe/Rome'), ('Europe/Samara', 'Europe/Samara'),
|
||||||
|
('Europe/San_Marino', 'Europe/San_Marino'),
|
||||||
|
('Europe/Sarajevo', 'Europe/Sarajevo'),
|
||||||
|
('Europe/Saratov', 'Europe/Saratov'),
|
||||||
|
('Europe/Simferopol', 'Europe/Simferopol'),
|
||||||
|
('Europe/Skopje', 'Europe/Skopje'), ('Europe/Sofia', 'Europe/Sofia'),
|
||||||
|
('Europe/Stockholm', 'Europe/Stockholm'),
|
||||||
|
('Europe/Tallinn', 'Europe/Tallinn'),
|
||||||
|
('Europe/Tirane', 'Europe/Tirane'),
|
||||||
|
('Europe/Tiraspol', 'Europe/Tiraspol'),
|
||||||
|
('Europe/Ulyanovsk', 'Europe/Ulyanovsk'),
|
||||||
|
('Europe/Uzhgorod', 'Europe/Uzhgorod'),
|
||||||
|
('Europe/Vaduz',
|
||||||
|
'Europe/Vaduz'), ('Europe/Vatican', 'Europe/Vatican'),
|
||||||
|
('Europe/Vienna', 'Europe/Vienna'),
|
||||||
|
('Europe/Vilnius', 'Europe/Vilnius'),
|
||||||
|
('Europe/Volgograd', 'Europe/Volgograd'),
|
||||||
|
('Europe/Warsaw',
|
||||||
|
'Europe/Warsaw'), ('Europe/Zagreb', 'Europe/Zagreb'),
|
||||||
|
('Europe/Zaporozhye', 'Europe/Zaporozhye'),
|
||||||
|
('Europe/Zurich', 'Europe/Zurich'), ('GB', 'GB'),
|
||||||
|
('GB-Eire', 'GB-Eire'), ('GMT', 'GMT'), ('GMT+0', 'GMT+0'),
|
||||||
|
('GMT-0', 'GMT-0'), ('GMT0', 'GMT0'), ('Greenwich', 'Greenwich'),
|
||||||
|
('HST', 'HST'), ('Hongkong', 'Hongkong'), ('Iceland', 'Iceland'),
|
||||||
|
('Indian/Antananarivo', 'Indian/Antananarivo'),
|
||||||
|
('Indian/Chagos', 'Indian/Chagos'),
|
||||||
|
('Indian/Christmas', 'Indian/Christmas'),
|
||||||
|
('Indian/Cocos', 'Indian/Cocos'), ('Indian/Comoro', 'Indian/Comoro'),
|
||||||
|
('Indian/Kerguelen', 'Indian/Kerguelen'),
|
||||||
|
('Indian/Mahe',
|
||||||
|
'Indian/Mahe'), ('Indian/Maldives', 'Indian/Maldives'),
|
||||||
|
('Indian/Mauritius', 'Indian/Mauritius'),
|
||||||
|
('Indian/Mayotte', 'Indian/Mayotte'),
|
||||||
|
('Indian/Reunion', 'Indian/Reunion'), ('Iran', 'Iran'),
|
||||||
|
('Israel', 'Israel'), ('Jamaica', 'Jamaica'), ('Japan', 'Japan'),
|
||||||
|
('Kwajalein', 'Kwajalein'), ('Libya', 'Libya'), ('MET', 'MET'),
|
||||||
|
('MST', 'MST'), ('MST7MDT', 'MST7MDT'),
|
||||||
|
('Mexico/BajaNorte', 'Mexico/BajaNorte'),
|
||||||
|
('Mexico/BajaSur', 'Mexico/BajaSur'),
|
||||||
|
('Mexico/General', 'Mexico/General'), ('NZ', 'NZ'),
|
||||||
|
('NZ-CHAT', 'NZ-CHAT'), ('Navajo', 'Navajo'), ('PRC', 'PRC'),
|
||||||
|
('PST8PDT', 'PST8PDT'), ('Pacific/Apia', 'Pacific/Apia'),
|
||||||
|
('Pacific/Auckland', 'Pacific/Auckland'),
|
||||||
|
('Pacific/Bougainville', 'Pacific/Bougainville'),
|
||||||
|
('Pacific/Chatham', 'Pacific/Chatham'),
|
||||||
|
('Pacific/Chuuk', 'Pacific/Chuuk'),
|
||||||
|
('Pacific/Easter', 'Pacific/Easter'),
|
||||||
|
('Pacific/Efate', 'Pacific/Efate'),
|
||||||
|
('Pacific/Enderbury', 'Pacific/Enderbury'),
|
||||||
|
('Pacific/Fakaofo', 'Pacific/Fakaofo'),
|
||||||
|
('Pacific/Fiji', 'Pacific/Fiji'),
|
||||||
|
('Pacific/Funafuti', 'Pacific/Funafuti'),
|
||||||
|
('Pacific/Galapagos', 'Pacific/Galapagos'),
|
||||||
|
('Pacific/Gambier', 'Pacific/Gambier'),
|
||||||
|
('Pacific/Guadalcanal', 'Pacific/Guadalcanal'),
|
||||||
|
('Pacific/Guam', 'Pacific/Guam'),
|
||||||
|
('Pacific/Honolulu', 'Pacific/Honolulu'),
|
||||||
|
('Pacific/Johnston', 'Pacific/Johnston'),
|
||||||
|
('Pacific/Kiritimati', 'Pacific/Kiritimati'),
|
||||||
|
('Pacific/Kosrae', 'Pacific/Kosrae'),
|
||||||
|
('Pacific/Kwajalein', 'Pacific/Kwajalein'),
|
||||||
|
('Pacific/Majuro', 'Pacific/Majuro'),
|
||||||
|
('Pacific/Marquesas', 'Pacific/Marquesas'),
|
||||||
|
('Pacific/Midway', 'Pacific/Midway'),
|
||||||
|
('Pacific/Nauru', 'Pacific/Nauru'), ('Pacific/Niue', 'Pacific/Niue'),
|
||||||
|
('Pacific/Norfolk', 'Pacific/Norfolk'),
|
||||||
|
('Pacific/Noumea', 'Pacific/Noumea'),
|
||||||
|
('Pacific/Pago_Pago', 'Pacific/Pago_Pago'),
|
||||||
|
('Pacific/Palau', 'Pacific/Palau'),
|
||||||
|
('Pacific/Pitcairn', 'Pacific/Pitcairn'),
|
||||||
|
('Pacific/Pohnpei', 'Pacific/Pohnpei'),
|
||||||
|
('Pacific/Ponape', 'Pacific/Ponape'),
|
||||||
|
('Pacific/Port_Moresby', 'Pacific/Port_Moresby'),
|
||||||
|
('Pacific/Rarotonga', 'Pacific/Rarotonga'),
|
||||||
|
('Pacific/Saipan', 'Pacific/Saipan'),
|
||||||
|
('Pacific/Samoa', 'Pacific/Samoa'),
|
||||||
|
('Pacific/Tahiti', 'Pacific/Tahiti'),
|
||||||
|
('Pacific/Tarawa', 'Pacific/Tarawa'),
|
||||||
|
('Pacific/Tongatapu', 'Pacific/Tongatapu'),
|
||||||
|
('Pacific/Truk', 'Pacific/Truk'), ('Pacific/Wake', 'Pacific/Wake'),
|
||||||
|
('Pacific/Wallis', 'Pacific/Wallis'), ('Pacific/Yap', 'Pacific/Yap'),
|
||||||
|
('Poland', 'Poland'), ('Portugal', 'Portugal'), ('ROC', 'ROC'),
|
||||||
|
('ROK', 'ROK'), ('Singapore', 'Singapore'), ('Turkey', 'Turkey'),
|
||||||
|
('UCT', 'UCT'), ('US/Alaska', 'US/Alaska'),
|
||||||
|
('US/Aleutian', 'US/Aleutian'), ('US/Arizona', 'US/Arizona'),
|
||||||
|
('US/Central', 'US/Central'), ('US/East-Indiana', 'US/East-Indiana'),
|
||||||
|
('US/Eastern', 'US/Eastern'), ('US/Hawaii', 'US/Hawaii'),
|
||||||
|
('US/Indiana-Starke', 'US/Indiana-Starke'),
|
||||||
|
('US/Michigan', 'US/Michigan'), ('US/Mountain', 'US/Mountain'),
|
||||||
|
('US/Pacific', 'US/Pacific'), ('US/Samoa',
|
||||||
|
'US/Samoa'), ('UTC', 'UTC'),
|
||||||
|
('Universal', 'Universal'), ('W-SU', 'W-SU'), ('WET', 'WET'),
|
||||||
|
('Zulu', 'Zulu')
|
||||||
|
],
|
||||||
|
default='UTC',
|
||||||
|
max_length=100
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Generated by Django 2.2 on 2019-05-21 19:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('collection', '0004_collectionrule_timezone'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='collectionrule',
|
||||||
|
name='favicon',
|
||||||
|
field=models.ImageField(blank=True, null=True, upload_to=''),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='collectionrule',
|
||||||
|
name='source',
|
||||||
|
field=models.CharField(default='source', max_length=100),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 2.2 on 2019-06-08 14:13
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('collection', '0005_auto_20190521_1941'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='collectionrule',
|
||||||
|
name='error',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
src/newsreader/news/collection/migrations/__init__.py
Normal file
0
src/newsreader/news/collection/migrations/__init__.py
Normal file
35
src/newsreader/news/collection/models.py
Normal file
35
src/newsreader/news/collection/models.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionRule(models.Model):
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
source = models.CharField(max_length=100)
|
||||||
|
|
||||||
|
url = models.URLField()
|
||||||
|
favicon = models.ImageField(blank=True, null=True)
|
||||||
|
|
||||||
|
timezone = models.CharField(
|
||||||
|
choices=((timezone, timezone) for timezone in pytz.all_timezones),
|
||||||
|
max_length=100,
|
||||||
|
default="UTC",
|
||||||
|
)
|
||||||
|
|
||||||
|
category = models.ForeignKey(
|
||||||
|
"posts.Category",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
verbose_name=_("Category"),
|
||||||
|
help_text=_("Posts from this rule will be tagged with this category"),
|
||||||
|
on_delete=models.SET_NULL
|
||||||
|
)
|
||||||
|
|
||||||
|
last_suceeded = models.DateTimeField(blank=True, null=True)
|
||||||
|
succeeded = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
error = models.CharField(max_length=255, blank=True, null=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
30
src/newsreader/news/collection/response_handler.py
Normal file
30
src/newsreader/news/collection/response_handler.py
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
from newsreader.news.collection.exceptions import (
|
||||||
|
StreamDeniedException,
|
||||||
|
StreamForbiddenException,
|
||||||
|
StreamNotFoundException,
|
||||||
|
StreamTimeOutException,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResponseHandler:
|
||||||
|
message_mapping = {
|
||||||
|
404: StreamNotFoundException,
|
||||||
|
401: StreamDeniedException,
|
||||||
|
403: StreamForbiddenException,
|
||||||
|
408: StreamTimeOutException,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, response):
|
||||||
|
self.response = response
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def handle_response(self):
|
||||||
|
status_code = self.response.status_code
|
||||||
|
|
||||||
|
if status_code in self.message_mapping:
|
||||||
|
raise self.message_mapping[status_code]
|
||||||
|
|
||||||
|
def __exit__(self, *args, **kwargs):
|
||||||
|
self.response = None
|
||||||
1
src/newsreader/news/collection/tests/__init__.py
Normal file
1
src/newsreader/news/collection/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from .feed import *
|
||||||
12
src/newsreader/news/collection/tests/factories.py
Normal file
12
src/newsreader/news/collection/tests/factories.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import factory
|
||||||
|
|
||||||
|
from newsreader.news.collection.models import CollectionRule
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionRuleFactory(factory.django.DjangoModelFactory):
|
||||||
|
class Meta:
|
||||||
|
model = CollectionRule
|
||||||
|
|
||||||
|
name = factory.Sequence(lambda n: "CollectionRule-{}".format(n))
|
||||||
|
source = factory.Faker("name")
|
||||||
|
url = factory.Faker("url")
|
||||||
5
src/newsreader/news/collection/tests/feed/__init__.py
Normal file
5
src/newsreader/news/collection/tests/feed/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from .builder import *
|
||||||
|
from .client import *
|
||||||
|
from .collector import *
|
||||||
|
from .duplicate_handler import *
|
||||||
|
from .stream import *
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from .tests import *
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
html_summary = '''
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<article>
|
||||||
|
<h1>This is clickbait</h1>
|
||||||
|
<p>This <strong>is</strong> <a href="https://www.bbc.com" media="durp">clickbait</a></p>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
'''
|
||||||
878
src/newsreader/news/collection/tests/feed/builder/mocks.py
Normal file
878
src/newsreader/news/collection/tests/feed/builder/mocks.py
Normal file
|
|
@ -0,0 +1,878 @@
|
||||||
|
from time import struct_time
|
||||||
|
|
||||||
|
from .mock_html import html_summary
|
||||||
|
|
||||||
|
simple_mock = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
|
|
||||||
|
multiple_mock = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '432',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg',
|
||||||
|
'width': '768'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 12:19:19 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)),
|
||||||
|
'summary': "Google's move to end business ties with Huawei will "
|
||||||
|
'affect current devices and future purchases.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': "Google's move to end business ties "
|
||||||
|
'with Huawei will affect current '
|
||||||
|
'devices and future purchases.'
|
||||||
|
},
|
||||||
|
'title': "Huawei's Android loss: How it affects you",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Huawei's Android loss: How it "
|
||||||
|
'affects you'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '549',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg',
|
||||||
|
'width': '976'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:32:38 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)),
|
||||||
|
'summary': 'Police are investigating the messages while an MP '
|
||||||
|
'calls for a protest exclusion zone "to protect '
|
||||||
|
'children".',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Police are investigating the '
|
||||||
|
'messages while an MP calls for a '
|
||||||
|
'protest exclusion zone "to protect '
|
||||||
|
'children".'
|
||||||
|
},
|
||||||
|
'title': 'Birmingham head teacher threatened over LGBT lessons',
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': 'Birmingham head teacher threatened '
|
||||||
|
'over LGBT lessons'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_without_identifier = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': None,
|
||||||
|
'link': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '432',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg',
|
||||||
|
'width': '768'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 12:19:19 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)),
|
||||||
|
'summary': "Google's move to end business ties with Huawei will "
|
||||||
|
'affect current devices and future purchases.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': "Google's move to end business ties "
|
||||||
|
'with Huawei will affect current '
|
||||||
|
'devices and future purchases.'
|
||||||
|
},
|
||||||
|
'title': "Huawei's Android loss: How it affects you",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Huawei's Android loss: How it "
|
||||||
|
'affects you'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_without_publish_date = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': None,
|
||||||
|
'published_parsed': None,
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '432',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg',
|
||||||
|
'width': '768'
|
||||||
|
}],
|
||||||
|
'summary': "Google's move to end business ties with Huawei will "
|
||||||
|
'affect current devices and future purchases.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': "Google's move to end business ties "
|
||||||
|
'with Huawei will affect current '
|
||||||
|
'devices and future purchases.'
|
||||||
|
},
|
||||||
|
'title': "Huawei's Android loss: How it affects you",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Huawei's Android loss: How it "
|
||||||
|
'affects you'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_without_url = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'published': None,
|
||||||
|
'published_parsed': None,
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'link': None,
|
||||||
|
'links': [],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '432',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg',
|
||||||
|
'width': '768'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
|
||||||
|
'summary': "Google's move to end business ties with Huawei will "
|
||||||
|
'affect current devices and future purchases.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': "Google's move to end business ties "
|
||||||
|
'with Huawei will affect current '
|
||||||
|
'devices and future purchases.'
|
||||||
|
},
|
||||||
|
'title': "Huawei's Android loss: How it affects you",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Huawei's Android loss: How it "
|
||||||
|
'affects you'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_without_body = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '549',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg',
|
||||||
|
'width': '976'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:32:38 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)),
|
||||||
|
'summary': None,
|
||||||
|
'summary_detail': {},
|
||||||
|
'title': 'Birmingham head teacher threatened over LGBT lessons',
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': 'Birmingham head teacher threatened '
|
||||||
|
'over LGBT lessons'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_without_author = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'author': None,
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '432',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg',
|
||||||
|
'width': '768'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 12:19:19 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)),
|
||||||
|
'summary': "Google's move to end business ties with Huawei will "
|
||||||
|
'affect current devices and future purchases.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': "Google's move to end business ties "
|
||||||
|
'with Huawei will affect current '
|
||||||
|
'devices and future purchases.'
|
||||||
|
},
|
||||||
|
'title': "Huawei's Android loss: How it affects you",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Huawei's Android loss: How it "
|
||||||
|
'affects you'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_without_entries = {
|
||||||
|
'entries': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_with_update_entries = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': '28f79ae4-8f9a-11e9-b143-00163ef6bee7',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'a5479c66-8fae-11e9-8422-00163ef6bee7',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '432',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg',
|
||||||
|
'width': '768'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 12:19:19 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)),
|
||||||
|
'summary': "Google's move to end business ties with Huawei will "
|
||||||
|
'affect current devices and future purchases.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': "Google's move to end business ties "
|
||||||
|
'with Huawei will affect current '
|
||||||
|
'devices and future purchases.'
|
||||||
|
},
|
||||||
|
'title': "Huawei's Android loss: How it affects you",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Huawei's Android loss: How it "
|
||||||
|
'affects you'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '549',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg',
|
||||||
|
'width': '976'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:32:38 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)),
|
||||||
|
'summary': 'Police are investigating the messages while an MP '
|
||||||
|
'calls for a protest exclusion zone "to protect '
|
||||||
|
'children".',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Police are investigating the '
|
||||||
|
'messages while an MP calls for a '
|
||||||
|
'protest exclusion zone "to protect '
|
||||||
|
'children".'
|
||||||
|
},
|
||||||
|
'title': 'Birmingham head teacher threatened over LGBT lessons',
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': 'Birmingham head teacher threatened '
|
||||||
|
'over LGBT lessons'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_with_html = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'author': 'A. Author',
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'summary': html_summary,
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
304
src/newsreader/news/collection/tests/feed/builder/tests.py
Normal file
304
src/newsreader/news/collection/tests/feed/builder/tests.py
Normal file
|
|
@ -0,0 +1,304 @@
|
||||||
|
from datetime import date, datetime, time
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from newsreader.news.collection.feed import FeedBuilder
|
||||||
|
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
||||||
|
from newsreader.news.collection.tests.feed.builder.mocks import *
|
||||||
|
from newsreader.news.posts.models import Post
|
||||||
|
from newsreader.news.posts.tests.factories import PostFactory
|
||||||
|
|
||||||
|
|
||||||
|
class FeedBuilderTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_basic_entry(self):
|
||||||
|
builder = FeedBuilder
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
|
||||||
|
with builder((simple_mock, mock_stream,)) as builder:
|
||||||
|
builder.save()
|
||||||
|
|
||||||
|
post = Post.objects.get()
|
||||||
|
|
||||||
|
d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37))
|
||||||
|
aware_date = pytz.utc.localize(d)
|
||||||
|
|
||||||
|
self.assertEquals(post.publication_date, aware_date)
|
||||||
|
self.assertEquals(Post.objects.count(), 1)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
post.remote_identifier,
|
||||||
|
"https://www.bbc.co.uk/news/world-us-canada-48338168"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
post.url,
|
||||||
|
"https://www.bbc.co.uk/news/world-us-canada-48338168"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
post.title,
|
||||||
|
"Trump's 'genocidal taunts' will not end Iran - Zarif"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_multiple_entries(self):
|
||||||
|
builder = FeedBuilder
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
|
||||||
|
with builder((multiple_mock, mock_stream,)) as builder:
|
||||||
|
builder.save()
|
||||||
|
|
||||||
|
posts = Post.objects.order_by("id")
|
||||||
|
self.assertEquals(Post.objects.count(), 3)
|
||||||
|
|
||||||
|
first_post = posts[0]
|
||||||
|
second_post = posts[1]
|
||||||
|
|
||||||
|
d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37))
|
||||||
|
aware_date = pytz.utc.localize(d)
|
||||||
|
|
||||||
|
self.assertEquals(first_post.publication_date, aware_date)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
first_post.remote_identifier,
|
||||||
|
"https://www.bbc.co.uk/news/world-us-canada-48338168"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
first_post.url,
|
||||||
|
"https://www.bbc.co.uk/news/world-us-canada-48338168"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
first_post.title,
|
||||||
|
"Trump's 'genocidal taunts' will not end Iran - Zarif"
|
||||||
|
)
|
||||||
|
|
||||||
|
d = datetime.combine(date(2019, 5, 20), time(hour=12, minute=19, second=19))
|
||||||
|
aware_date = pytz.utc.localize(d)
|
||||||
|
|
||||||
|
self.assertEquals(second_post.publication_date, aware_date)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
second_post.remote_identifier,
|
||||||
|
"https://www.bbc.co.uk/news/technology-48334739"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
second_post.url,
|
||||||
|
"https://www.bbc.co.uk/news/technology-48334739"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
second_post.title,
|
||||||
|
"Huawei's Android loss: How it affects you"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_entry_without_remote_identifier(self):
|
||||||
|
builder = FeedBuilder
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
|
||||||
|
with builder((mock_without_identifier, mock_stream,)) as builder:
|
||||||
|
builder.save()
|
||||||
|
|
||||||
|
posts = Post.objects.order_by("id")
|
||||||
|
self.assertEquals(Post.objects.count(), 2)
|
||||||
|
|
||||||
|
first_post = posts[0]
|
||||||
|
|
||||||
|
d = datetime.combine(date(2019, 5, 20), time(hour=16, minute=7, second=37))
|
||||||
|
aware_date = pytz.utc.localize(d)
|
||||||
|
|
||||||
|
self.assertEquals(first_post.publication_date, aware_date)
|
||||||
|
|
||||||
|
self.assertEquals(first_post.remote_identifier, None)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
first_post.url,
|
||||||
|
"https://www.bbc.co.uk/news/world-us-canada-48338168"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
first_post.title,
|
||||||
|
"Trump's 'genocidal taunts' will not end Iran - Zarif"
|
||||||
|
)
|
||||||
|
|
||||||
|
@freeze_time("2019-10-30 12:30:00")
|
||||||
|
def test_entry_without_publication_date(self):
|
||||||
|
builder = FeedBuilder
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
|
||||||
|
with builder((mock_without_publish_date, mock_stream,)) as builder:
|
||||||
|
builder.save()
|
||||||
|
|
||||||
|
posts = Post.objects.order_by("id")
|
||||||
|
self.assertEquals(Post.objects.count(), 2)
|
||||||
|
|
||||||
|
first_post = posts[0]
|
||||||
|
second_post = posts[1]
|
||||||
|
|
||||||
|
self.assertEquals(first_post.created, timezone.now())
|
||||||
|
self.assertEquals(
|
||||||
|
first_post.remote_identifier,
|
||||||
|
'https://www.bbc.co.uk/news/world-us-canada-48338168'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(second_post.created, timezone.now())
|
||||||
|
self.assertEquals(
|
||||||
|
second_post.remote_identifier,
|
||||||
|
'https://www.bbc.co.uk/news/technology-48334739'
|
||||||
|
)
|
||||||
|
|
||||||
|
@freeze_time("2019-10-30 12:30:00")
|
||||||
|
def test_entry_without_url(self):
|
||||||
|
builder = FeedBuilder
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
|
||||||
|
with builder((mock_without_url, mock_stream,)) as builder:
|
||||||
|
builder.save()
|
||||||
|
|
||||||
|
posts = Post.objects.order_by("id")
|
||||||
|
self.assertEquals(Post.objects.count(), 2)
|
||||||
|
|
||||||
|
first_post = posts[0]
|
||||||
|
second_post = posts[1]
|
||||||
|
|
||||||
|
self.assertEquals(first_post.created, timezone.now())
|
||||||
|
self.assertEquals(
|
||||||
|
first_post.remote_identifier,
|
||||||
|
'https://www.bbc.co.uk/news/world-us-canada-48338168'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(second_post.created, timezone.now())
|
||||||
|
self.assertEquals(
|
||||||
|
second_post.remote_identifier,
|
||||||
|
'https://www.bbc.co.uk/news/technology-48334739'
|
||||||
|
)
|
||||||
|
|
||||||
|
@freeze_time("2019-10-30 12:30:00")
|
||||||
|
def test_entry_without_body(self):
|
||||||
|
builder = FeedBuilder
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
|
||||||
|
with builder((mock_without_body, mock_stream,)) as builder:
|
||||||
|
builder.save()
|
||||||
|
|
||||||
|
posts = Post.objects.order_by("id")
|
||||||
|
self.assertEquals(Post.objects.count(), 2)
|
||||||
|
|
||||||
|
first_post = posts[0]
|
||||||
|
second_post = posts[1]
|
||||||
|
|
||||||
|
self.assertEquals(first_post.created, timezone.now())
|
||||||
|
self.assertEquals(
|
||||||
|
first_post.remote_identifier,
|
||||||
|
'https://www.bbc.co.uk/news/world-us-canada-48338168'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(second_post.created, timezone.now())
|
||||||
|
self.assertEquals(
|
||||||
|
second_post.remote_identifier,
|
||||||
|
'https://www.bbc.co.uk/news/uk-england-birmingham-48339080'
|
||||||
|
)
|
||||||
|
|
||||||
|
@freeze_time("2019-10-30 12:30:00")
|
||||||
|
def test_entry_without_author(self):
|
||||||
|
builder = FeedBuilder
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
|
||||||
|
with builder((mock_without_author, mock_stream,)) as builder:
|
||||||
|
builder.save()
|
||||||
|
|
||||||
|
posts = Post.objects.order_by("id")
|
||||||
|
self.assertEquals(Post.objects.count(), 2)
|
||||||
|
|
||||||
|
first_post = posts[0]
|
||||||
|
second_post = posts[1]
|
||||||
|
|
||||||
|
self.assertEquals(first_post.created, timezone.now())
|
||||||
|
self.assertEquals(
|
||||||
|
first_post.remote_identifier,
|
||||||
|
'https://www.bbc.co.uk/news/world-us-canada-48338168'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(second_post.created, timezone.now())
|
||||||
|
self.assertEquals(
|
||||||
|
second_post.remote_identifier,
|
||||||
|
'https://www.bbc.co.uk/news/technology-48334739'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_entries(self):
|
||||||
|
builder = FeedBuilder
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
|
||||||
|
with builder((mock_without_entries, mock_stream,)) as builder:
|
||||||
|
builder.save()
|
||||||
|
|
||||||
|
self.assertEquals(Post.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_update_entries(self):
|
||||||
|
builder = FeedBuilder
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
|
||||||
|
existing_first_post = PostFactory.create(
|
||||||
|
remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_second_post = PostFactory.create(
|
||||||
|
remote_identifier="a5479c66-8fae-11e9-8422-00163ef6bee7", rule=rule
|
||||||
|
)
|
||||||
|
|
||||||
|
with builder((mock_with_update_entries, mock_stream,)) as builder:
|
||||||
|
builder.save()
|
||||||
|
|
||||||
|
self.assertEquals(Post.objects.count(), 3)
|
||||||
|
|
||||||
|
existing_first_post.refresh_from_db()
|
||||||
|
existing_second_post.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
existing_first_post.title,
|
||||||
|
"Trump's 'genocidal taunts' will not end Iran - Zarif"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
existing_second_post.title,
|
||||||
|
"Huawei's Android loss: How it affects you"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_html_sanitizing(self):
|
||||||
|
builder = FeedBuilder
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
|
||||||
|
with builder((mock_with_html, mock_stream,)) as builder:
|
||||||
|
builder.save()
|
||||||
|
|
||||||
|
post = Post.objects.get()
|
||||||
|
|
||||||
|
self.assertEquals(Post.objects.count(), 1)
|
||||||
|
|
||||||
|
self.assertTrue("<html>" not in post.body)
|
||||||
|
self.assertTrue("<body>" not in post.body)
|
||||||
|
self.assertTrue("<article>" not in post.body)
|
||||||
|
self.assertTrue("<h1>" not in post.body)
|
||||||
|
self.assertTrue("<strong>" not in post.body)
|
||||||
|
self.assertTrue('<a href="https://www.bbc.com">' in post.body)
|
||||||
|
self.assertTrue("<p>" in post.body)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from .tests import *
|
||||||
61
src/newsreader/news/collection/tests/feed/client/mocks.py
Normal file
61
src/newsreader/news/collection/tests/feed/client/mocks.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
from time import struct_time
|
||||||
|
|
||||||
|
simple_mock = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
90
src/newsreader/news/collection/tests/feed/client/tests.py
Normal file
90
src/newsreader/news/collection/tests/feed/client/tests.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from newsreader.news.collection.exceptions import (
|
||||||
|
StreamDeniedException,
|
||||||
|
StreamException,
|
||||||
|
StreamFieldException,
|
||||||
|
StreamNotFoundException,
|
||||||
|
StreamTimeOutException,
|
||||||
|
)
|
||||||
|
from newsreader.news.collection.feed import FeedClient
|
||||||
|
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
||||||
|
from newsreader.news.collection.tests.feed.client.mocks import simple_mock
|
||||||
|
|
||||||
|
|
||||||
|
class FeedClientTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.patched_read = patch(
|
||||||
|
'newsreader.news.collection.feed.FeedStream.read'
|
||||||
|
)
|
||||||
|
self.mocked_read = self.patched_read.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
def test_client_retrieves_single_rules(self):
|
||||||
|
rule = CollectionRuleFactory.create()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
self.mocked_read.return_value = (simple_mock, mock_stream)
|
||||||
|
|
||||||
|
with FeedClient([rule]) as client:
|
||||||
|
for data, stream in client:
|
||||||
|
self.assertEquals(data, simple_mock)
|
||||||
|
self.assertEquals(stream, mock_stream)
|
||||||
|
|
||||||
|
self.mocked_read.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_client_catches_stream_exception(self):
|
||||||
|
rule = CollectionRuleFactory.create()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
self.mocked_read.side_effect = StreamException("Stream exception")
|
||||||
|
|
||||||
|
with FeedClient([rule]) as client:
|
||||||
|
for data, stream in client:
|
||||||
|
self.assertEquals(data, {"entries": []})
|
||||||
|
self.assertEquals(stream.rule.error, "Stream exception")
|
||||||
|
self.assertEquals(stream.rule.succeeded, False)
|
||||||
|
|
||||||
|
self.mocked_read.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_client_catches_stream_not_found_exception(self):
|
||||||
|
rule = CollectionRuleFactory.create()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
self.mocked_read.side_effect = StreamNotFoundException("Stream not found")
|
||||||
|
|
||||||
|
with FeedClient([rule]) as client:
|
||||||
|
for data, stream in client:
|
||||||
|
self.assertEquals(data, {"entries": []})
|
||||||
|
self.assertEquals(stream.rule.error, "Stream not found")
|
||||||
|
self.assertEquals(stream.rule.succeeded, False)
|
||||||
|
|
||||||
|
self.mocked_read.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_client_catches_stream_denied_exception(self):
|
||||||
|
rule = CollectionRuleFactory.create()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
self.mocked_read.side_effect = StreamDeniedException("Stream denied")
|
||||||
|
|
||||||
|
with FeedClient([rule]) as client:
|
||||||
|
for data, stream in client:
|
||||||
|
self.assertEquals(data, {"entries": []})
|
||||||
|
self.assertEquals(stream.rule.error, "Stream denied")
|
||||||
|
self.assertEquals(stream.rule.succeeded, False)
|
||||||
|
|
||||||
|
self.mocked_read.assert_called_once_with()
|
||||||
|
|
||||||
|
def test_client_catches_stream_timed_out(self):
|
||||||
|
rule = CollectionRuleFactory.create()
|
||||||
|
mock_stream = MagicMock(rule=rule)
|
||||||
|
self.mocked_read.side_effect = StreamTimeOutException("Stream timed out")
|
||||||
|
|
||||||
|
with FeedClient([rule]) as client:
|
||||||
|
for data, stream in client:
|
||||||
|
self.assertEquals(data, {"entries": []})
|
||||||
|
self.assertEquals(stream.rule.error, "Stream timed out")
|
||||||
|
self.assertEquals(stream.rule.succeeded, False)
|
||||||
|
|
||||||
|
self.mocked_read.assert_called_once_with()
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from .tests import *
|
||||||
430
src/newsreader/news/collection/tests/feed/collector/mocks.py
Normal file
430
src/newsreader/news/collection/tests/feed/collector/mocks.py
Normal file
|
|
@ -0,0 +1,430 @@
|
||||||
|
from time import struct_time
|
||||||
|
|
||||||
|
multiple_mock = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '432',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg',
|
||||||
|
'width': '768'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 12:19:19 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)),
|
||||||
|
'summary': "Google's move to end business ties with Huawei will "
|
||||||
|
'affect current devices and future purchases.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': "Google's move to end business ties "
|
||||||
|
'with Huawei will affect current '
|
||||||
|
'devices and future purchases.'
|
||||||
|
},
|
||||||
|
'title': "Huawei's Android loss: How it affects you",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Huawei's Android loss: How it "
|
||||||
|
'affects you'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '549',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg',
|
||||||
|
'width': '976'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:32:38 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)),
|
||||||
|
'summary': 'Police are investigating the messages while an MP '
|
||||||
|
'calls for a protest exclusion zone "to protect '
|
||||||
|
'children".',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Police are investigating the '
|
||||||
|
'messages while an MP calls for a '
|
||||||
|
'protest exclusion zone "to protect '
|
||||||
|
'children".'
|
||||||
|
},
|
||||||
|
'title': 'Birmingham head teacher threatened over LGBT lessons',
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': 'Birmingham head teacher threatened '
|
||||||
|
'over LGBT lessons'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
|
|
||||||
|
empty_mock = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicate_mock = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '432',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg',
|
||||||
|
'width': '768'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 12:19:19 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)),
|
||||||
|
'summary': "Google's move to end business ties with Huawei will "
|
||||||
|
'affect current devices and future purchases.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': "Google's move to end business ties "
|
||||||
|
'with Huawei will affect current '
|
||||||
|
'devices and future purchases.'
|
||||||
|
},
|
||||||
|
'title': "Huawei's Android loss: How it affects you",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Huawei's Android loss: How it "
|
||||||
|
'affects you'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '549',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg',
|
||||||
|
'width': '976'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:32:38 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)),
|
||||||
|
'summary': 'Police are investigating the messages while an MP '
|
||||||
|
'calls for a protest exclusion zone "to protect '
|
||||||
|
'children".',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Police are investigating the '
|
||||||
|
'messages while an MP calls for a '
|
||||||
|
'protest exclusion zone "to protect '
|
||||||
|
'children".'
|
||||||
|
},
|
||||||
|
'title': 'Birmingham head teacher threatened over LGBT lessons',
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': 'Birmingham head teacher threatened '
|
||||||
|
'over LGBT lessons'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
|
|
||||||
|
multiple_update_mock = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [
|
||||||
|
{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/technology-48334739',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '432',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/4789/production/_107031381_mediaitem107028670.jpg',
|
||||||
|
'width': '768'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 12:19:19 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)),
|
||||||
|
'summary': "Google's move to end business ties with Huawei will "
|
||||||
|
'affect current devices and future purchases.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': "Google's move to end business ties "
|
||||||
|
'with Huawei will affect current '
|
||||||
|
'devices and future purchases.'
|
||||||
|
},
|
||||||
|
'title': "Huawei's Android loss: How it affects you",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Huawei's Android loss: How it "
|
||||||
|
'affects you'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/uk-england-birmingham-48339080',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '549',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/11D67/production/_107036037_lgbtheadjpg.jpg',
|
||||||
|
'width': '976'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:32:38 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)),
|
||||||
|
'summary': 'Police are investigating the messages while an MP '
|
||||||
|
'calls for a protest exclusion zone "to protect '
|
||||||
|
'children".',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Police are investigating the '
|
||||||
|
'messages while an MP calls for a '
|
||||||
|
'protest exclusion zone "to protect '
|
||||||
|
'children".'
|
||||||
|
},
|
||||||
|
'title': 'Birmingham head teacher threatened over LGBT lessons',
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': 'Birmingham head teacher threatened '
|
||||||
|
'over LGBT lessons'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
251
src/newsreader/news/collection/tests/feed/collector/tests.py
Normal file
251
src/newsreader/news/collection/tests/feed/collector/tests.py
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
from datetime import date, datetime, time
|
||||||
|
from time import struct_time
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from newsreader.news.collection.feed import FeedCollector
|
||||||
|
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
||||||
|
from newsreader.news.collection.tests.feed.collector.mocks import (
|
||||||
|
duplicate_mock,
|
||||||
|
empty_mock,
|
||||||
|
multiple_mock,
|
||||||
|
multiple_update_mock,
|
||||||
|
)
|
||||||
|
from newsreader.news.collection.utils import build_publication_date
|
||||||
|
from newsreader.news.posts.models import Post
|
||||||
|
from newsreader.news.posts.tests.factories import PostFactory
|
||||||
|
|
||||||
|
|
||||||
|
class FeedCollectorTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.patched_get = patch(
|
||||||
|
'newsreader.news.collection.feed.requests.get'
|
||||||
|
)
|
||||||
|
self.mocked_get = self.patched_get.start()
|
||||||
|
|
||||||
|
self.patched_parse = patch(
|
||||||
|
'newsreader.news.collection.feed.FeedStream.parse'
|
||||||
|
)
|
||||||
|
self.mocked_parse = self.patched_parse.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
@freeze_time("2019-10-30 12:30:00")
|
||||||
|
def test_simple_batch(self):
|
||||||
|
self.mocked_parse.return_value = multiple_mock
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
|
||||||
|
collector = FeedCollector()
|
||||||
|
collector.collect()
|
||||||
|
|
||||||
|
rule.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(Post.objects.count(), 3)
|
||||||
|
self.assertEquals(rule.succeeded, True)
|
||||||
|
self.assertEquals(rule.last_suceeded, timezone.now())
|
||||||
|
self.assertEquals(rule.error, None)
|
||||||
|
|
||||||
|
@freeze_time("2019-10-30 12:30:00")
|
||||||
|
def test_emtpy_batch(self):
|
||||||
|
self.mocked_get.return_value = MagicMock(status_code=200)
|
||||||
|
self.mocked_parse.return_value = empty_mock
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
|
||||||
|
collector = FeedCollector()
|
||||||
|
collector.collect()
|
||||||
|
|
||||||
|
rule.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(Post.objects.count(), 0)
|
||||||
|
self.assertEquals(rule.succeeded, True)
|
||||||
|
self.assertEquals(rule.error, None)
|
||||||
|
self.assertEquals(rule.last_suceeded, timezone.now())
|
||||||
|
|
||||||
|
def test_not_found(self):
|
||||||
|
self.mocked_get.return_value = MagicMock(status_code=404)
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
|
||||||
|
collector = FeedCollector()
|
||||||
|
collector.collect()
|
||||||
|
|
||||||
|
rule.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(Post.objects.count(), 0)
|
||||||
|
self.assertEquals(rule.succeeded, False)
|
||||||
|
self.assertEquals(rule.error, "Stream not found")
|
||||||
|
|
||||||
|
def test_denied(self):
|
||||||
|
self.mocked_get.return_value = MagicMock(status_code=404)
|
||||||
|
last_suceeded = timezone.make_aware(
|
||||||
|
datetime.combine(date=date(2019, 10, 30), time=time(12, 30))
|
||||||
|
)
|
||||||
|
rule = CollectionRuleFactory(last_suceeded=last_suceeded)
|
||||||
|
|
||||||
|
collector = FeedCollector()
|
||||||
|
collector.collect()
|
||||||
|
|
||||||
|
rule.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(Post.objects.count(), 0)
|
||||||
|
self.assertEquals(rule.succeeded, False)
|
||||||
|
self.assertEquals(rule.error, "Stream not found")
|
||||||
|
self.assertEquals(rule.last_suceeded, last_suceeded)
|
||||||
|
|
||||||
|
def test_forbidden(self):
|
||||||
|
self.mocked_get.return_value = MagicMock(status_code=403)
|
||||||
|
last_suceeded = timezone.make_aware(
|
||||||
|
datetime.combine(date=date(2019, 10, 30), time=time(12, 30))
|
||||||
|
)
|
||||||
|
rule = CollectionRuleFactory(last_suceeded=last_suceeded)
|
||||||
|
|
||||||
|
collector = FeedCollector()
|
||||||
|
collector.collect()
|
||||||
|
|
||||||
|
rule.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(Post.objects.count(), 0)
|
||||||
|
self.assertEquals(rule.succeeded, False)
|
||||||
|
self.assertEquals(rule.error, "Stream forbidden")
|
||||||
|
self.assertEquals(rule.last_suceeded, last_suceeded)
|
||||||
|
|
||||||
|
def test_timed_out(self):
|
||||||
|
self.mocked_get.return_value = MagicMock(status_code=408)
|
||||||
|
last_suceeded = timezone.make_aware(
|
||||||
|
datetime.combine(date=date(2019, 10, 30), time=time(12, 30))
|
||||||
|
)
|
||||||
|
rule = CollectionRuleFactory(last_suceeded=last_suceeded)
|
||||||
|
|
||||||
|
collector = FeedCollector()
|
||||||
|
collector.collect()
|
||||||
|
|
||||||
|
rule.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(Post.objects.count(), 0)
|
||||||
|
self.assertEquals(rule.succeeded, False)
|
||||||
|
self.assertEquals(rule.error, "Stream timed out")
|
||||||
|
self.assertEquals(rule.last_suceeded, last_suceeded)
|
||||||
|
|
||||||
|
@freeze_time("2019-10-30 12:30:00")
|
||||||
|
def test_duplicates(self):
|
||||||
|
self.mocked_parse.return_value = duplicate_mock
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
|
||||||
|
_, aware_datetime = build_publication_date(
|
||||||
|
struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
pytz.utc
|
||||||
|
)
|
||||||
|
|
||||||
|
first_post = PostFactory(
|
||||||
|
url="https://www.bbc.co.uk/news/world-us-canada-48338168",
|
||||||
|
title="Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
body="Foreign Minister Mohammad Javad Zarif says the US "
|
||||||
|
"president should try showing Iranians some respect.",
|
||||||
|
publication_date=aware_datetime,
|
||||||
|
rule=rule
|
||||||
|
)
|
||||||
|
|
||||||
|
_, aware_datetime = build_publication_date(
|
||||||
|
struct_time((2019, 5, 20, 12, 19, 19, 0, 140, 0,)),
|
||||||
|
pytz.utc
|
||||||
|
)
|
||||||
|
|
||||||
|
second_post = PostFactory(
|
||||||
|
url="https://www.bbc.co.uk/news/technology-48334739",
|
||||||
|
title="Huawei's Android loss: How it affects you",
|
||||||
|
body="Google's move to end business ties with Huawei will "
|
||||||
|
"affect current devices and future purchases.",
|
||||||
|
publication_date=aware_datetime,
|
||||||
|
rule=rule
|
||||||
|
)
|
||||||
|
|
||||||
|
_, aware_datetime = build_publication_date(
|
||||||
|
struct_time((2019, 5, 20, 16, 32, 38, 0, 140, 0)),
|
||||||
|
pytz.utc
|
||||||
|
)
|
||||||
|
|
||||||
|
third_post = PostFactory(
|
||||||
|
url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080",
|
||||||
|
title="Birmingham head teacher threatened over LGBT lessons",
|
||||||
|
body="Police are investigating the messages while an MP "
|
||||||
|
"calls for a protest exclusion zone \"to protect "
|
||||||
|
"children\".",
|
||||||
|
publication_date=aware_datetime,
|
||||||
|
rule=rule
|
||||||
|
)
|
||||||
|
|
||||||
|
collector = FeedCollector()
|
||||||
|
collector.collect(rules=[rule])
|
||||||
|
|
||||||
|
rule.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(Post.objects.count(), 3)
|
||||||
|
self.assertEquals(rule.succeeded, True)
|
||||||
|
self.assertEquals(rule.last_suceeded, timezone.now())
|
||||||
|
self.assertEquals(rule.error, None)
|
||||||
|
|
||||||
|
@freeze_time("2019-02-22 12:30:00")
|
||||||
|
def test_items_with_identifiers_get_updated(self):
|
||||||
|
self.mocked_parse.return_value = multiple_update_mock
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
|
||||||
|
first_post = PostFactory(
|
||||||
|
remote_identifier="https://www.bbc.co.uk/news/world-us-canada-48338168",
|
||||||
|
url="https://www.bbc.co.uk/",
|
||||||
|
title="Trump",
|
||||||
|
body="Foreign Minister Mohammad Javad Zarif",
|
||||||
|
publication_date=timezone.now(),
|
||||||
|
rule=rule
|
||||||
|
)
|
||||||
|
|
||||||
|
second_post = PostFactory(
|
||||||
|
remote_identifier="https://www.bbc.co.uk/news/technology-48334739",
|
||||||
|
url="https://www.bbc.co.uk/",
|
||||||
|
title="Huawei's Android loss: How it affects you",
|
||||||
|
body="Google's move to end business ties with Huawei will",
|
||||||
|
publication_date=timezone.now(),
|
||||||
|
rule=rule
|
||||||
|
)
|
||||||
|
|
||||||
|
third_post = PostFactory(
|
||||||
|
remote_identifier="https://www.bbc.co.uk/news/uk-england-birmingham-48339080",
|
||||||
|
url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080",
|
||||||
|
title="Birmingham head teacher threatened over LGBT lessons",
|
||||||
|
body="Police are investigating the messages while an MP",
|
||||||
|
publication_date=timezone.now(),
|
||||||
|
rule=rule
|
||||||
|
)
|
||||||
|
|
||||||
|
collector = FeedCollector()
|
||||||
|
collector.collect(rules=[rule])
|
||||||
|
|
||||||
|
rule.refresh_from_db()
|
||||||
|
first_post.refresh_from_db()
|
||||||
|
second_post.refresh_from_db()
|
||||||
|
third_post.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEquals(Post.objects.count(), 3)
|
||||||
|
self.assertEquals(rule.succeeded, True)
|
||||||
|
self.assertEquals(rule.last_suceeded, timezone.now())
|
||||||
|
self.assertEquals(rule.error, None)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
first_post.title,
|
||||||
|
"Trump's 'genocidal taunts' will not end Iran - Zarif"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
second_post.title,
|
||||||
|
"Huawei's Android loss: How it affects you"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEquals(
|
||||||
|
third_post.title,
|
||||||
|
'Birmingham head teacher threatened over LGBT lessons'
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from .tests import *
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from newsreader.news.collection.feed import FeedDuplicateHandler
|
||||||
|
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
||||||
|
from newsreader.news.posts.models import Post
|
||||||
|
from newsreader.news.posts.tests.factories import PostFactory
|
||||||
|
|
||||||
|
|
||||||
|
class FeedDuplicateHandlerTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_duplicate_entries_with_remote_identifiers(self):
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
existing_post = PostFactory.create(
|
||||||
|
remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule
|
||||||
|
)
|
||||||
|
new_post = PostFactory.build(
|
||||||
|
remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7",
|
||||||
|
title="title got updated",
|
||||||
|
rule=rule
|
||||||
|
)
|
||||||
|
|
||||||
|
with FeedDuplicateHandler(rule) as duplicate_handler:
|
||||||
|
posts_gen = duplicate_handler.check([new_post])
|
||||||
|
posts = list(posts_gen)
|
||||||
|
|
||||||
|
post = posts[0]
|
||||||
|
|
||||||
|
self.assertEquals(len(posts), 1)
|
||||||
|
self.assertEquals(post.publication_date, new_post.publication_date)
|
||||||
|
self.assertTrue(post.publication_date != existing_post.publication_date)
|
||||||
|
self.assertTrue(post.title != existing_post.title)
|
||||||
|
|
||||||
|
def test_duplicate_entries_in_recent_database(self):
|
||||||
|
PostFactory.create_batch(size=20)
|
||||||
|
|
||||||
|
publication_date = timezone.now()
|
||||||
|
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
existing_post = PostFactory.create(
|
||||||
|
url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080",
|
||||||
|
title="Birmingham head teacher threatened over LGBT lessons",
|
||||||
|
body="Google's move to end business ties with Huawei will affect current devices",
|
||||||
|
publication_date=publication_date,
|
||||||
|
remote_identifier=None,
|
||||||
|
rule=rule
|
||||||
|
)
|
||||||
|
new_post = PostFactory.build(
|
||||||
|
url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080",
|
||||||
|
title="Birmingham head teacher threatened over LGBT lessons",
|
||||||
|
body="Google's move to end business ties with Huawei will affect current devices",
|
||||||
|
publication_date=publication_date,
|
||||||
|
remote_identifier=None,
|
||||||
|
rule=rule
|
||||||
|
)
|
||||||
|
|
||||||
|
with FeedDuplicateHandler(rule) as duplicate_handler:
|
||||||
|
posts_gen = duplicate_handler.check([new_post])
|
||||||
|
posts = list(posts_gen)
|
||||||
|
|
||||||
|
self.assertEquals(len(posts), 0)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from .tests import *
|
||||||
61
src/newsreader/news/collection/tests/feed/stream/mocks.py
Normal file
61
src/newsreader/news/collection/tests/feed/stream/mocks.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
from time import struct_time
|
||||||
|
|
||||||
|
simple_mock = {
|
||||||
|
'bozo': 0,
|
||||||
|
'encoding': 'utf-8',
|
||||||
|
'entries': [{
|
||||||
|
'guidislink': False,
|
||||||
|
'href': '',
|
||||||
|
'id': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/world-us-canada-48338168',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'media_thumbnail': [{
|
||||||
|
'height': '1152',
|
||||||
|
'url': 'http://c.files.bbci.co.uk/7605/production/_107031203_mediaitem107031202.jpg',
|
||||||
|
'width': '2048'
|
||||||
|
}],
|
||||||
|
'published': 'Mon, 20 May 2019 16:07:37 GMT',
|
||||||
|
'published_parsed': struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)),
|
||||||
|
'summary': 'Foreign Minister Mohammad Javad Zarif says the US '
|
||||||
|
'president should try showing Iranians some respect.',
|
||||||
|
'summary_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/html',
|
||||||
|
'value': 'Foreign Minister Mohammad Javad '
|
||||||
|
'Zarif says the US president should '
|
||||||
|
'try showing Iranians some '
|
||||||
|
'respect.'
|
||||||
|
},
|
||||||
|
'title': "Trump's 'genocidal taunts' will not end Iran - Zarif",
|
||||||
|
'title_detail': {
|
||||||
|
'base': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'language': None,
|
||||||
|
'type': 'text/plain',
|
||||||
|
'value': "Trump's 'genocidal taunts' will not "
|
||||||
|
'end Iran - Zarif'
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
'feed': {
|
||||||
|
'image': {
|
||||||
|
'href': 'https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/',
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
'language': 'en-gb',
|
||||||
|
'link': 'https://www.bbc.co.uk/news/'
|
||||||
|
},
|
||||||
|
'links': [{
|
||||||
|
'href': 'https://www.bbc.co.uk/news/',
|
||||||
|
'rel': 'alternate',
|
||||||
|
'type': 'text/html'
|
||||||
|
}],
|
||||||
|
'title': 'BBC News - Home',
|
||||||
|
},
|
||||||
|
'href': 'http://feeds.bbci.co.uk/news/rss.xml',
|
||||||
|
'status': 200,
|
||||||
|
'version': 'rss20'
|
||||||
|
}
|
||||||
109
src/newsreader/news/collection/tests/feed/stream/tests.py
Normal file
109
src/newsreader/news/collection/tests/feed/stream/tests.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from newsreader.news.collection.exceptions import (
|
||||||
|
StreamDeniedException,
|
||||||
|
StreamException,
|
||||||
|
StreamForbiddenException,
|
||||||
|
StreamNotFoundException,
|
||||||
|
StreamParseException,
|
||||||
|
StreamTimeOutException,
|
||||||
|
)
|
||||||
|
from newsreader.news.collection.feed import FeedStream
|
||||||
|
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
||||||
|
from newsreader.news.collection.tests.feed.stream.mocks import simple_mock
|
||||||
|
|
||||||
|
|
||||||
|
class FeedStreamTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.patched_get = patch(
|
||||||
|
'newsreader.news.collection.feed.requests.get'
|
||||||
|
)
|
||||||
|
self.mocked_get = self.patched_get.start()
|
||||||
|
|
||||||
|
self.patched_parse = patch(
|
||||||
|
'newsreader.news.collection.feed.FeedStream.parse'
|
||||||
|
)
|
||||||
|
self.mocked_parse = self.patched_parse.start()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
patch.stopall()
|
||||||
|
|
||||||
|
def test_simple_stream(self):
|
||||||
|
self.mocked_parse.return_value = simple_mock
|
||||||
|
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
stream = FeedStream(rule)
|
||||||
|
return_value = stream.read()
|
||||||
|
|
||||||
|
self.mocked_get.assert_called_once_with(rule.url)
|
||||||
|
self.assertEquals(return_value, (simple_mock, stream))
|
||||||
|
|
||||||
|
def test_stream_raises_exception(self):
|
||||||
|
self.mocked_parse.side_effect = StreamException
|
||||||
|
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
stream = FeedStream(rule)
|
||||||
|
|
||||||
|
with self.assertRaises(StreamException):
|
||||||
|
stream.read()
|
||||||
|
|
||||||
|
self.mocked_get.assert_called_once_with(rule.url)
|
||||||
|
|
||||||
|
def test_stream_raises_denied_exception(self):
|
||||||
|
self.mocked_get.return_value = MagicMock(status_code=401)
|
||||||
|
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
stream = FeedStream(rule)
|
||||||
|
|
||||||
|
with self.assertRaises(StreamDeniedException):
|
||||||
|
stream.read()
|
||||||
|
|
||||||
|
self.mocked_get.assert_called_once_with(rule.url)
|
||||||
|
|
||||||
|
def test_stream_raises_not_found_exception(self):
|
||||||
|
self.mocked_get.return_value = MagicMock(status_code=404)
|
||||||
|
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
stream = FeedStream(rule)
|
||||||
|
|
||||||
|
with self.assertRaises(StreamNotFoundException):
|
||||||
|
stream.read()
|
||||||
|
|
||||||
|
self.mocked_get.assert_called_once_with(rule.url)
|
||||||
|
|
||||||
|
def test_stream_raises_time_out_exception(self):
|
||||||
|
self.mocked_get.return_value = MagicMock(status_code=408)
|
||||||
|
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
stream = FeedStream(rule)
|
||||||
|
|
||||||
|
with self.assertRaises(StreamTimeOutException):
|
||||||
|
stream.read()
|
||||||
|
|
||||||
|
self.mocked_get.assert_called_once_with(rule.url)
|
||||||
|
|
||||||
|
def test_stream_raises_forbidden_exception(self):
|
||||||
|
self.mocked_get.return_value = MagicMock(status_code=403)
|
||||||
|
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
stream = FeedStream(rule)
|
||||||
|
|
||||||
|
with self.assertRaises(StreamForbiddenException):
|
||||||
|
stream.read()
|
||||||
|
|
||||||
|
self.mocked_get.assert_called_once_with(rule.url)
|
||||||
|
|
||||||
|
@patch("newsreader.news.collection.feed.parse")
|
||||||
|
def test_stream_raises_parse_exception(self, mocked_parse):
|
||||||
|
self.mocked_get.return_value = MagicMock(status_code=200)
|
||||||
|
mocked_parse.side_effect = TypeError
|
||||||
|
self.patched_parse.stop()
|
||||||
|
|
||||||
|
rule = CollectionRuleFactory()
|
||||||
|
stream = FeedStream(rule)
|
||||||
|
|
||||||
|
with self.assertRaises(StreamParseException):
|
||||||
|
stream.read()
|
||||||
13
src/newsreader/news/collection/utils.py
Normal file
13
src/newsreader/news/collection/utils.py
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
from datetime import datetime
|
||||||
|
from time import mktime
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
def build_publication_date(dt, tz):
|
||||||
|
try:
|
||||||
|
naive_datetime = datetime.fromtimestamp(mktime(dt))
|
||||||
|
published_parsed = timezone.make_aware(naive_datetime, timezone=tz)
|
||||||
|
except TypeError:
|
||||||
|
return False, None
|
||||||
|
return True, published_parsed
|
||||||
3
src/newsreader/news/collection/views.py
Normal file
3
src/newsreader/news/collection/views.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
0
src/newsreader/news/posts/__init__.py
Normal file
0
src/newsreader/news/posts/__init__.py
Normal file
39
src/newsreader/news/posts/admin.py
Normal file
39
src/newsreader/news/posts/admin.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from newsreader.news.posts.models import Category, Post
|
||||||
|
|
||||||
|
|
||||||
|
class PostAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"publication_date",
|
||||||
|
"author",
|
||||||
|
"rule",
|
||||||
|
"title",
|
||||||
|
)
|
||||||
|
list_display_links = ("title", )
|
||||||
|
list_filter = ("rule", )
|
||||||
|
|
||||||
|
ordering = ("-publication_date", "title")
|
||||||
|
|
||||||
|
fields = (
|
||||||
|
"title",
|
||||||
|
"body",
|
||||||
|
"author",
|
||||||
|
"publication_date",
|
||||||
|
"url",
|
||||||
|
"remote_identifier",
|
||||||
|
"category",
|
||||||
|
)
|
||||||
|
|
||||||
|
search_fields = ["title"]
|
||||||
|
|
||||||
|
def rule(self, obj):
|
||||||
|
return obj.rule
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryAdmin(admin.ModelAdmin):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Post, PostAdmin)
|
||||||
|
admin.site.register(Category, CategoryAdmin)
|
||||||
5
src/newsreader/news/posts/apps.py
Normal file
5
src/newsreader/news/posts/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PostsConfig(AppConfig):
|
||||||
|
name = 'posts'
|
||||||
78
src/newsreader/news/posts/migrations/0001_initial.py
Normal file
78
src/newsreader/news/posts/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Generated by Django 2.2 on 2019-04-10 20:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('collection', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Category',
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
'id',
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name='ID'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('modified', models.DateTimeField(auto_now=True)),
|
||||||
|
('name', models.CharField(max_length=50)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Post',
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
'id',
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name='ID'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
('created', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('modified', models.DateTimeField(auto_now=True)),
|
||||||
|
('title', models.CharField(max_length=200)),
|
||||||
|
('body', models.TextField()),
|
||||||
|
('source', models.CharField(max_length=200)),
|
||||||
|
('publication_date', models.DateTimeField()),
|
||||||
|
('url', models.URLField()),
|
||||||
|
('remote_identifier', models.CharField(max_length=500)),
|
||||||
|
(
|
||||||
|
'category',
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to='posts.Category'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'rule',
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to='collection.CollectionRule'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Generated by Django 2.2 on 2019-05-20 20:06
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('posts', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='category',
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Category',
|
||||||
|
'verbose_name_plural': 'Categories'
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 2.2 on 2019-05-20 20:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('posts', '0002_auto_20190520_2206'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='category',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(max_length=50, unique=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 2.2 on 2019-05-21 19:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('posts', '0003_auto_20190520_2031'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='post',
|
||||||
|
name='source',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='post',
|
||||||
|
name='author',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 2.2 on 2019-06-08 10:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('posts', '0004_auto_20190521_1941'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='post',
|
||||||
|
name='body',
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='post',
|
||||||
|
name='remote_identifier',
|
||||||
|
field=models.CharField(blank=True, max_length=500, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 2.2 on 2019-06-08 15:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('posts', '0005_auto_20190608_1054'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='post',
|
||||||
|
name='body',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='post',
|
||||||
|
name='publication_date',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='post',
|
||||||
|
name='title',
|
||||||
|
field=models.CharField(blank=True, max_length=200, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='post',
|
||||||
|
name='url',
|
||||||
|
field=models.URLField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
src/newsreader/news/posts/migrations/__init__.py
Normal file
0
src/newsreader/news/posts/migrations/__init__.py
Normal file
34
src/newsreader/news/posts/models.py
Normal file
34
src/newsreader/news/posts/models.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
|
from newsreader.core.models import TimeStampedModel
|
||||||
|
from newsreader.news.collection.models import CollectionRule
|
||||||
|
|
||||||
|
|
||||||
|
class Post(TimeStampedModel):
|
||||||
|
title = models.CharField(max_length=200, blank=True, null=True)
|
||||||
|
body = models.TextField(blank=True, null=True)
|
||||||
|
author = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
publication_date = models.DateTimeField(blank=True, null=True)
|
||||||
|
url = models.URLField(blank=True, null=True)
|
||||||
|
|
||||||
|
rule = models.ForeignKey(CollectionRule, on_delete=models.CASCADE)
|
||||||
|
remote_identifier = models.CharField(max_length=500, blank=True, null=True)
|
||||||
|
|
||||||
|
category = models.ForeignKey(
|
||||||
|
'Category', blank=True, null=True, on_delete=models.PROTECT
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "Post-{}".format(self.pk)
|
||||||
|
|
||||||
|
|
||||||
|
class Category(TimeStampedModel):
|
||||||
|
name = models.CharField(max_length=50, unique=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Category")
|
||||||
|
verbose_name_plural = _("Categories")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
0
src/newsreader/news/posts/tests/__init__.py
Normal file
0
src/newsreader/news/posts/tests/__init__.py
Normal file
28
src/newsreader/news/posts/tests/factories.py
Normal file
28
src/newsreader/news/posts/tests/factories.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import factory
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from newsreader.news.collection.tests.factories import CollectionRuleFactory
|
||||||
|
from newsreader.news.posts.models import Category, Post
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryFactory(factory.django.DjangoModelFactory):
|
||||||
|
class Meta:
|
||||||
|
model = Category
|
||||||
|
|
||||||
|
name = factory.Sequence(lambda n: "Category-{}".format(n))
|
||||||
|
|
||||||
|
|
||||||
|
class PostFactory(factory.django.DjangoModelFactory):
|
||||||
|
class Meta:
|
||||||
|
model = Post
|
||||||
|
|
||||||
|
title = factory.Faker("sentence")
|
||||||
|
body = factory.Faker("paragraph")
|
||||||
|
author = factory.Faker("name")
|
||||||
|
publication_date = factory.Faker('date_time_this_year', tzinfo=pytz.utc)
|
||||||
|
url = factory.Faker('url')
|
||||||
|
remote_identifier = factory.Faker("url")
|
||||||
|
|
||||||
|
rule = factory.SubFactory(CollectionRuleFactory)
|
||||||
|
|
||||||
|
category = factory.SubFactory(CategoryFactory)
|
||||||
3
src/newsreader/news/posts/views.py
Normal file
3
src/newsreader/news/posts/views.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
6
src/newsreader/urls.py
Normal file
6
src/newsreader/urls.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
]
|
||||||
10
src/newsreader/utils/formatter.sh
Normal file
10
src/newsreader/utils/formatter.sh
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/bash
|
||||||
|
FILES=$(git diff --cached --name-only --diff-filter=ACM "*.py" | sed 's| |\\ |g')
|
||||||
|
|
||||||
|
if [ ! -z "$FILES" ]; then
|
||||||
|
# Format all selected files
|
||||||
|
echo "$FILES" | xargs ./env/bin/isort
|
||||||
|
|
||||||
|
# Add back the modified/prettified files to staging
|
||||||
|
echo "$FILES" | xargs git add
|
||||||
|
fi
|
||||||
13
src/newsreader/utils/pre-commit
Normal file
13
src/newsreader/utils/pre-commit
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Check if the directory is the root directory
|
||||||
|
if [ ! -d ".git/" ]; then
|
||||||
|
echo "Please commit from within the root directory"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run every file inside the pre-commit.d directory
|
||||||
|
for file in .git/hooks/pre-commit.d/*
|
||||||
|
do
|
||||||
|
. $file
|
||||||
|
done
|
||||||
16
src/newsreader/wsgi.py
Normal file
16
src/newsreader/wsgi.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
"""
|
||||||
|
WSGI config for newsreader project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'newsreader.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue