0.2.3 #99

Merged
sonny merged 112 commits from development into master 2020-05-23 16:58:42 +02:00
13 changed files with 65 additions and 86 deletions
Showing only changes of commit ab0b24b3d2 - Show all commits

View file

@ -1,8 +1,5 @@
import json import json
from typing import Iterable
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager from django.contrib.auth.models import UserManager as DjangoUserManager
from django.db import models from django.db import models
@ -12,7 +9,7 @@ from django_celery_beat.models import IntervalSchedule, PeriodicTask
class UserManager(DjangoUserManager): class UserManager(DjangoUserManager):
def _create_user(self, email, password, **extra_fields) -> get_user_model: def _create_user(self, email, password, **extra_fields):
""" """
Create and save a user with the given username, email, and password. Create and save a user with the given username, email, and password.
""" """
@ -24,14 +21,12 @@ class UserManager(DjangoUserManager):
user.save(using=self._db) user.save(using=self._db)
return user return user
def create_user(self, email: str, password=None, **extra_fields) -> get_user_model: def create_user(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False) extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False) extra_fields.setdefault("is_superuser", False)
return self._create_user(email, password, **extra_fields) return self._create_user(email, password, **extra_fields)
def create_superuser( def create_superuser(self, email, password, **extra_fields):
self, email: str, password: str, **extra_fields
) -> get_user_model:
extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True) extra_fields.setdefault("is_superuser", True)
@ -62,7 +57,7 @@ class User(AbstractUser):
USERNAME_FIELD = "email" USERNAME_FIELD = "email"
REQUIRED_FIELDS = [] REQUIRED_FIELDS = []
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
if not self.task: if not self.task:
@ -80,6 +75,6 @@ class User(AbstractUser):
self.save() self.save()
def delete(self, *args, **kwargs) -> Iterable: def delete(self, *args, **kwargs):
self.task.delete() self.task.delete()
return super().delete(*args, **kwargs) return super().delete(*args, **kwargs)

View file

@ -1,5 +1,3 @@
from typing import ContextManager, Dict, Optional, Tuple
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@ -10,13 +8,13 @@ from newsreader.news.collection.utils import fetch
class Stream: class Stream:
def __init__(self, rule: CollectionRule) -> None: def __init__(self, rule):
self.rule = rule self.rule = rule
def read(self) -> Tuple: def read(self):
raise NotImplementedError raise NotImplementedError
def parse(self, payload: bytes) -> Dict: def parse(self, payload):
raise NotImplementedError raise NotImplementedError
class Meta: class Meta:
@ -26,16 +24,16 @@ class Stream:
class Client: class Client:
stream = Stream stream = Stream
def __init__(self, rules: Optional[CollectionRule] = None) -> None: def __init__(self, rules=None):
self.rules = rules if rules else CollectionRule.objects.all() self.rules = rules if rules else CollectionRule.objects.all()
def __enter__(self) -> ContextManager: def __enter__(self):
for rule in self.rules: for rule in self.rules:
stream = self.stream(rule) stream = self.stream(rule)
yield stream.read() yield stream.read()
def __exit__(self, *args, **kwargs) -> None: def __exit__(self, *args, **kwargs):
pass pass
class Meta: class Meta:
@ -45,20 +43,20 @@ class Client:
class Builder: class Builder:
instances = [] instances = []
def __init__(self, stream: Tuple) -> None: def __init__(self, stream):
self.stream = stream self.stream = stream
def __enter__(self) -> ContextManager: def __enter__(self):
self.create_posts(self.stream) self.create_posts(self.stream)
return self return self
def __exit__(self, *args, **kwargs) -> None: def __exit__(self, *args, **kwargs):
pass pass
def create_posts(self, stream: Tuple) -> None: def create_posts(self, stream):
pass pass
def save(self) -> None: def save(self):
pass pass
class Meta: class Meta:
@ -69,13 +67,11 @@ class Collector:
client = None client = None
builder = None builder = None
def __init__( def __init__(self, client=None, builder=None):
self, client: Optional[Client] = None, builder: Optional[Builder] = None
) -> None:
self.client = client if client else self.client self.client = client if client else self.client
self.builder = builder if builder else self.builder self.builder = builder if builder else self.builder
def collect(self, rules: Optional[QuerySet] = None) -> None: def collect(self, rules=None):
with self.client(rules=rules) as client: with self.client(rules=rules) as client:
for data, stream in client: for data, stream in client:
with self.builder((data, stream)) as builder: with self.builder((data, stream)) as builder:
@ -86,15 +82,15 @@ class Collector:
class WebsiteStream(Stream): class WebsiteStream(Stream):
def __init__(self, url: str) -> None: def __init__(self, url):
self.url = url self.url = url
def read(self) -> Tuple: def read(self):
response = fetch(self.url) response = fetch(self.url)
return (self.parse(response.content), self) return (self.parse(response.content), self)
def parse(self, payload: bytes) -> BeautifulSoup: def parse(self, payload):
try: try:
return BeautifulSoup(payload, "lxml") return BeautifulSoup(payload, "lxml")
except TypeError: except TypeError:
@ -102,10 +98,10 @@ class WebsiteStream(Stream):
class URLBuilder(Builder): class URLBuilder(Builder):
def __enter__(self) -> ContextManager: def __enter__(self):
return self return self
def build(self) -> Tuple: def build(self):
data, stream = self.stream data, stream = self.stream
rule = stream.rule rule = stream.rule

View file

@ -1,5 +1,3 @@
from django.db.models.query import QuerySet
from rest_framework import status from rest_framework import status
from rest_framework.generics import ( from rest_framework.generics import (
GenericAPIView, GenericAPIView,
@ -24,7 +22,7 @@ class ListRuleView(ListCreateAPIView):
serializer_class = RuleSerializer serializer_class = RuleSerializer
pagination_class = ResultSetPagination pagination_class = ResultSetPagination
def get_queryset(self) -> QuerySet: def get_queryset(self):
user = self.request.user user = self.request.user
return self.queryset.filter(user=user).order_by("-created") return self.queryset.filter(user=user).order_by("-created")
@ -41,7 +39,7 @@ class NestedRuleView(ListAPIView):
pagination_class = LargeResultSetPagination pagination_class = LargeResultSetPagination
filter_backends = [ReadFilter] filter_backends = [ReadFilter]
def get_queryset(self) -> QuerySet: def get_queryset(self):
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
# Default permission is IsOwner, therefore there shouldn't have to be # Default permission is IsOwner, therefore there shouldn't have to be

View file

@ -1,5 +1,4 @@
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import ContextManager, List, Optional
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
from newsreader.news.collection.base import ( from newsreader.news.collection.base import (
@ -18,7 +17,7 @@ LINK_RELS = ["icon", "shortcut icon", "apple-touch-icon", "apple-touch-icon-prec
class FaviconBuilder(Builder): class FaviconBuilder(Builder):
def build(self) -> None: def build(self):
rule, soup = self.stream rule, soup = self.stream
url = self.parse(soup, rule.website_url) url = self.parse(soup, rule.website_url)
@ -27,7 +26,7 @@ class FaviconBuilder(Builder):
rule.favicon = url rule.favicon = url
rule.save() rule.save()
def parse(self, soup, website_url) -> Optional[str]: def parse(self, soup, website_url):
if not soup.head: if not soup.head:
return return
@ -48,7 +47,7 @@ class FaviconBuilder(Builder):
return url return url
def parse_links(self, links: List) -> Optional[str]: def parse_links(self, links):
favicons = set() favicons = set()
icons = set() icons = set()
@ -73,10 +72,10 @@ class FaviconBuilder(Builder):
class FaviconClient(Client): class FaviconClient(Client):
stream = WebsiteStream stream = WebsiteStream
def __init__(self, streams: List) -> None: def __init__(self, streams):
self.streams = streams self.streams = streams
def __enter__(self) -> ContextManager: def __enter__(self):
with ThreadPoolExecutor(max_workers=10) as executor: with ThreadPoolExecutor(max_workers=10) as executor:
futures = { futures = {
executor.submit(stream.read): rule for rule, stream in self.streams executor.submit(stream.read): rule for rule, stream in self.streams
@ -97,7 +96,7 @@ class FaviconCollector(Collector):
feed_client, favicon_client = (FeedClient, FaviconClient) feed_client, favicon_client = (FeedClient, FaviconClient)
url_builder, favicon_builder = (URLBuilder, FaviconBuilder) url_builder, favicon_builder = (URLBuilder, FaviconBuilder)
def collect(self, rules: Optional[List] = None) -> None: def collect(self, rules=None):
streams = [] streams = []
with self.feed_client(rules=rules) as client: with self.feed_client(rules=rules) as client:

View file

@ -1,5 +1,4 @@
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import ContextManager, Dict, Generator, List, Optional, Tuple
from django.db.models.fields import CharField, TextField from django.db.models.fields import CharField, TextField
from django.template.defaultfilters import truncatechars from django.template.defaultfilters import truncatechars
@ -27,7 +26,7 @@ from newsreader.news.core.models import Post
class FeedBuilder(Builder): class FeedBuilder(Builder):
instances = [] instances = []
def __enter__(self) -> ContextManager: def __enter__(self):
_, stream = self.stream _, stream = self.stream
self.instances = [] self.instances = []
self.existing_posts = { self.existing_posts = {
@ -36,7 +35,7 @@ class FeedBuilder(Builder):
return super().__enter__() return super().__enter__()
def create_posts(self, stream: Tuple) -> None: def create_posts(self, stream):
data, stream = stream data, stream = stream
entries = [] entries = []
@ -51,7 +50,7 @@ class FeedBuilder(Builder):
self.instances = [post for post in posts] self.instances = [post for post in posts]
def build(self, entries: List, rule: CollectionRule) -> Generator[Post, None, None]: def build(self, entries, rule):
field_mapping = { field_mapping = {
"id": "remote_identifier", "id": "remote_identifier",
"title": "title", "title": "title",
@ -90,7 +89,7 @@ class FeedBuilder(Builder):
yield Post(**data) yield Post(**data)
def sanitize_fragment(self, fragment: str) -> Optional[str]: def sanitize_fragment(self, fragment):
if not fragment: if not fragment:
return "" return ""
@ -117,23 +116,23 @@ class FeedBuilder(Builder):
return value return value
def get_content(self, items: List) -> str: def get_content(self, items):
content = "\n ".join([item.get("value") for item in items]) content = "\n ".join([item.get("value") for item in items])
return self.sanitize_fragment(content) return self.sanitize_fragment(content)
def save(self) -> None: def save(self):
for post in self.instances: for post in self.instances:
post.save() post.save()
class FeedStream(Stream): class FeedStream(Stream):
def read(self) -> Tuple: def read(self):
url = self.rule.url url = self.rule.url
response = fetch(url) response = fetch(url)
return (self.parse(response.content), self) return (self.parse(response.content), self)
def parse(self, payload: bytes) -> Dict: def parse(self, payload):
try: try:
return parse(payload) return parse(payload)
except TypeError as e: except TypeError as e:
@ -143,7 +142,7 @@ class FeedStream(Stream):
class FeedClient(Client): class FeedClient(Client):
stream = FeedStream stream = FeedStream
def __enter__(self) -> ContextManager: def __enter__(self):
streams = [self.stream(rule) for rule in self.rules] streams = [self.stream(rule) for rule in self.rules]
with ThreadPoolExecutor(max_workers=10) as executor: with ThreadPoolExecutor(max_workers=10) as executor:
@ -175,19 +174,19 @@ class FeedCollector(Collector):
class FeedDuplicateHandler: class FeedDuplicateHandler:
def __init__(self, rule: CollectionRule) -> None: def __init__(self, rule):
self.queryset = rule.posts.all() self.queryset = rule.posts.all()
def __enter__(self) -> ContextManager: def __enter__(self):
self.existing_identifiers = self.queryset.filter( self.existing_identifiers = self.queryset.filter(
remote_identifier__isnull=False remote_identifier__isnull=False
).values_list("remote_identifier", flat=True) ).values_list("remote_identifier", flat=True)
return self return self
def __exit__(self, *args, **kwargs) -> None: def __exit__(self, *args, **kwargs):
pass pass
def check(self, instances: List) -> Generator[Post, None, None]: def check(self, instances):
for instance in instances: for instance in instances:
if instance.remote_identifier in self.existing_identifiers: if instance.remote_identifier in self.existing_identifiers:
existing_post = self.handle_duplicate(instance) existing_post = self.handle_duplicate(instance)
@ -200,7 +199,7 @@ class FeedDuplicateHandler:
yield instance yield instance
def in_database(self, post: Post) -> Optional[bool]: def in_database(self, post):
values = { values = {
"url": post.url, "url": post.url,
"title": post.title, "title": post.title,
@ -212,7 +211,7 @@ class FeedDuplicateHandler:
if self.is_duplicate(existing_post, values): if self.is_duplicate(existing_post, values):
return True return True
def is_duplicate(self, existing_post: Post, values: Dict) -> bool: def is_duplicate(self, existing_post, values):
for key, value in values.items(): for key, value in values.items():
existing_value = getattr(existing_post, key, None) existing_value = getattr(existing_post, key, None)
if existing_value != value: if existing_value != value:
@ -220,7 +219,7 @@ class FeedDuplicateHandler:
return True return True
def handle_duplicate(self, instance: Post) -> Optional[Post]: def handle_duplicate(self, instance):
try: try:
existing_instance = self.queryset.get( existing_instance = self.queryset.get(
remote_identifier=instance.remote_identifier remote_identifier=instance.remote_identifier

View file

@ -13,7 +13,7 @@ class CollectionRuleForm(forms.ModelForm):
choices=((timezone, timezone) for timezone in pytz.all_timezones), choices=((timezone, timezone) for timezone in pytz.all_timezones),
) )
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user") self.user = kwargs.pop("user")
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@ -21,7 +21,7 @@ class CollectionRuleForm(forms.ModelForm):
if self.user: if self.user:
self.fields["category"].queryset = Category.objects.filter(user=self.user) self.fields["category"].queryset = Category.objects.filter(user=self.user)
def save(self, commit=True) -> CollectionRule: def save(self, commit=True):
instance = super().save(commit=False) instance = super().save(commit=False)
instance.user = self.user instance.user = self.user

View file

@ -1,5 +1,3 @@
from typing import ContextManager
from requests.exceptions import ConnectionError as RequestConnectionError from requests.exceptions import ConnectionError as RequestConnectionError
from newsreader.news.collection.exceptions import ( from newsreader.news.collection.exceptions import (
@ -22,10 +20,10 @@ class ResponseHandler:
exception_mapping = {RequestConnectionError: StreamConnectionError} exception_mapping = {RequestConnectionError: StreamConnectionError}
def __enter__(self) -> ContextManager: def __enter__(self):
return self return self
def handle_response(self, response) -> None: def handle_response(self, response):
status_code = response.status_code status_code = response.status_code
if status_code in self.status_code_mapping: if status_code in self.status_code_mapping:
@ -40,5 +38,5 @@ class ResponseHandler:
message = getattr(exception, "message", str(exception)) message = getattr(exception, "message", str(exception))
raise stream_exception(message=message) from exception raise stream_exception(message=message) from exception
def __exit__(self, *args, **kwargs) -> None: def __exit__(self, *args, **kwargs):
pass pass

View file

@ -1,6 +1,4 @@
from datetime import datetime, tzinfo from datetime import datetime
from time import struct_time
from typing import Tuple
from django.utils import timezone from django.utils import timezone
@ -12,7 +10,7 @@ from requests.models import Response
from newsreader.news.collection.response_handler import ResponseHandler from newsreader.news.collection.response_handler import ResponseHandler
def build_publication_date(dt: struct_time, tz: tzinfo) -> Tuple: def build_publication_date(dt, tz):
try: try:
naive_datetime = datetime(*dt[:6]) naive_datetime = datetime(*dt[:6])
published_parsed = timezone.make_aware(naive_datetime, timezone=tz) published_parsed = timezone.make_aware(naive_datetime, timezone=tz)
@ -21,7 +19,7 @@ def build_publication_date(dt: struct_time, tz: tzinfo) -> Tuple:
return published_parsed, True return published_parsed, True
def fetch(url: str) -> Response: def fetch(url):
with ResponseHandler() as response_handler: with ResponseHandler() as response_handler:
try: try:
response = requests.get(url) response = requests.get(url)

View file

@ -1,5 +1,3 @@
from typing import Dict, Iterable
from django.contrib import messages from django.contrib import messages
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -17,7 +15,7 @@ from newsreader.utils.opml import parse_opml
class CollectionRuleViewMixin: class CollectionRuleViewMixin:
queryset = CollectionRule.objects.order_by("name") queryset = CollectionRule.objects.order_by("name")
def get_queryset(self) -> Iterable: def get_queryset(self):
user = self.request.user user = self.request.user
return self.queryset.filter(user=user) return self.queryset.filter(user=user)
@ -26,7 +24,7 @@ class CollectionRuleDetailMixin:
success_url = reverse_lazy("rules") success_url = reverse_lazy("rules")
form_class = CollectionRuleForm form_class = CollectionRuleForm
def get_context_data(self, **kwargs) -> Dict: def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs) context_data = super().get_context_data(**kwargs)
rules = Category.objects.filter(user=self.request.user).order_by("name") rules = Category.objects.filter(user=self.request.user).order_by("name")
@ -37,7 +35,7 @@ class CollectionRuleDetailMixin:
return context_data return context_data
def get_form_kwargs(self) -> Dict: def get_form_kwargs(self):
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user kwargs["user"] = self.request.user
return kwargs return kwargs

View file

@ -1,5 +1,4 @@
from django.db.models import Q from django.db.models import Q
from django.db.models.query import QuerySet
from rest_framework import status from rest_framework import status
from rest_framework.generics import ( from rest_framework.generics import (
@ -63,7 +62,7 @@ class NestedRuleCategoryView(ListAPIView):
queryset = Category.objects.prefetch_related("rules").all() queryset = Category.objects.prefetch_related("rules").all()
serializer_class = RuleSerializer serializer_class = RuleSerializer
def get_queryset(self) -> QuerySet: def get_queryset(self):
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
# Default permission is IsOwner, therefore there shouldn't have to be # Default permission is IsOwner, therefore there shouldn't have to be

View file

@ -27,7 +27,7 @@ class CategoryForm(forms.ModelForm):
self.initial["user"] = self.user self.initial["user"] = self.user
def save(self, commit=True) -> Category: def save(self, commit=True):
instance = super().save(commit=False) instance = super().save(commit=False)
if commit: if commit:

View file

@ -1,3 +1,4 @@
from newsreadern.news.collection.serializers import RuleSerializer
from rest_framework import serializers from rest_framework import serializers
from newsreader.news.posts.models import Category, Post from newsreader.news.posts.models import Category, Post
@ -8,7 +9,7 @@ class CategorySerializer(serializers.ModelSerializer):
def get_rules(self, instance): def get_rules(self, instance):
rules = instance.rules.order_by("-modified", "-created") rules = instance.rules.order_by("-modified", "-created")
serializer = CollectionRuleSerializer(rules, many=True) serializer = RuleSerializer(rules, many=True)
return serializer.data return serializer.data
class Meta: class Meta:

View file

@ -1,5 +1,3 @@
from typing import Dict, Iterable
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.views.generic.base import TemplateView from django.views.generic.base import TemplateView
from django.views.generic.edit import CreateView, UpdateView from django.views.generic.edit import CreateView, UpdateView
@ -14,7 +12,7 @@ class NewsView(TemplateView):
template_name = "core/homepage.html" template_name = "core/homepage.html"
# TODO serialize objects to show filled main page # TODO serialize objects to show filled main page
def get_context_data(self, **kwargs) -> Dict: def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
user = self.request.user user = self.request.user
@ -35,7 +33,7 @@ class NewsView(TemplateView):
class CategoryViewMixin: class CategoryViewMixin:
queryset = Category.objects.prefetch_related("rules").order_by("name") queryset = Category.objects.prefetch_related("rules").order_by("name")
def get_queryset(self) -> Iterable: def get_queryset(self):
user = self.request.user user = self.request.user
return self.queryset.filter(user=user) return self.queryset.filter(user=user)
@ -44,7 +42,7 @@ class CategoryDetailMixin:
success_url = reverse_lazy("categories") success_url = reverse_lazy("categories")
form_class = CategoryForm form_class = CategoryForm
def get_context_data(self, **kwargs) -> Dict: def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs) context_data = super().get_context_data(**kwargs)
rules = CollectionRule.objects.filter(user=self.request.user).order_by("name") rules = CollectionRule.objects.filter(user=self.request.user).order_by("name")