diff --git a/docker-compose.yml b/docker-compose.yml index 27d2969..c7dc5ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,8 @@ services: - DJANGO_SETTINGS_MODULE=newsreader.conf.docker depends_on: - rabbitmq + volumes: + - .:/app django: build: context: . @@ -45,6 +47,8 @@ services: volumes: - .:/app - static-files:/app/src/newsreader/static + stdin_open: true + tty: true webpack: build: context: . diff --git a/src/newsreader/accounts/admin.py b/src/newsreader/accounts/admin.py index c223687..e0b5eed 100644 --- a/src/newsreader/accounts/admin.py +++ b/src/newsreader/accounts/admin.py @@ -1,9 +1,19 @@ +from django import forms from django.contrib import admin from django.utils.translation import ugettext as _ from newsreader.accounts.models import User +class UserAdminForm(forms.ModelForm): + class Meta: + widgets = { + "email": forms.EmailInput(attrs={"size": "50"}), + "reddit_access_token": forms.TextInput(attrs={"size": "90"}), + "reddit_refresh_token": forms.TextInput(attrs={"size": "90"}), + } + + class UserAdmin(admin.ModelAdmin): list_display = ("email", "last_name", "date_joined", "is_active") list_filter = ("is_active", "is_staff", "is_superuser") @@ -11,17 +21,20 @@ class UserAdmin(admin.ModelAdmin): search_fields = ["email", "last_name", "first_name"] readonly_fields = ("last_login", "date_joined") + + form = UserAdminForm fieldsets = ( ( _("User settings"), {"fields": ("email", "first_name", "last_name", "is_active")}, ), + ( + _("Reddit settings"), + {"fields": ("reddit_access_token", "reddit_refresh_token")}, + ), ( _("Permission settings"), - { - "classes": ("collapse",), - "fields": ("is_staff", "is_superuser", "groups", "user_permissions"), - }, + {"classes": ("collapse",), "fields": ("is_staff", "is_superuser")}, ), (_("Misc settings"), {"fields": ("date_joined", "last_login")}), ) diff --git a/src/newsreader/accounts/migrations/0010_auto_20200603_2230.py b/src/newsreader/accounts/migrations/0010_auto_20200603_2230.py new file mode 100644 index 0000000..294ff31 --- /dev/null +++ b/src/newsreader/accounts/migrations/0010_auto_20200603_2230.py @@ -0,0 +1,21 @@ +# Generated by Django 3.0.5 on 2020-06-03 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("accounts", "0009_auto_20200524_1218")] + + operations = [ + migrations.AddField( + model_name="user", + name="reddit_access_token", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="user", + name="reddit_refresh_token", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/src/newsreader/accounts/models.py b/src/newsreader/accounts/models.py index 18eba07..b8aaa64 100644 --- a/src/newsreader/accounts/models.py +++ b/src/newsreader/accounts/models.py @@ -50,6 +50,9 @@ class User(AbstractUser): verbose_name="collection task", ) + reddit_refresh_token = models.CharField(max_length=255, blank=True, null=True) + reddit_access_token = models.CharField(max_length=255, blank=True, null=True) + username = None objects = UserManager() @@ -69,7 +72,7 @@ class User(AbstractUser): enabled=True, interval=task_interval, name=f"{self.email}-collection-task", - task="newsreader.news.collection.tasks.FeedTask", + task="FeedTask", args=json.dumps([self.pk]), ) diff --git a/src/newsreader/accounts/templates/accounts/components/settings-form.html b/src/newsreader/accounts/templates/accounts/components/settings-form.html index ff06cb7..7942354 100644 --- a/src/newsreader/accounts/templates/accounts/components/settings-form.html +++ b/src/newsreader/accounts/templates/accounts/components/settings-form.html @@ -13,6 +13,18 @@ {% include "components/form/confirm-button.html" %} + + {% if reddit_authorization_url %} + + {% trans "Authorize Reddit account" %} + + {% endif %} + + {% if reddit_refresh_url %} + + {% trans "Refresh Reddit access token" %} + + {% endif %} {% endblock actions %} diff --git a/src/newsreader/accounts/templates/accounts/views/reddit.html b/src/newsreader/accounts/templates/accounts/views/reddit.html new file mode 100644 index 0000000..b393bbe --- /dev/null +++ b/src/newsreader/accounts/templates/accounts/views/reddit.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block content %} +
+
+ {% if error %} +

Reddit authorization failed

+

{{ error }}

+ {% elif access_token and refresh_token %} +

Reddit account is linked

+

Your reddit account was successfully linked.

+ {% endif %} + +

Return to settings page

+
+
+{% endblock %} diff --git a/src/newsreader/accounts/tests/test_settings.py b/src/newsreader/accounts/tests/test_settings.py new file mode 100644 index 0000000..d093ea4 --- /dev/null +++ b/src/newsreader/accounts/tests/test_settings.py @@ -0,0 +1,161 @@ +from unittest.mock import patch +from urllib.parse import urlencode +from uuid import uuid4 + +from django.core.cache import cache +from django.test import TestCase +from django.urls import reverse + +from newsreader.accounts.models import User +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import StreamTooManyException + + +class SettingsViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.url = reverse("accounts:settings") + + def test_simple(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Authorize Reddit account") + + def test_user_credential_change(self): + response = self.client.post( + reverse("accounts:settings"), + {"first_name": "First name", "last_name": "Last name"}, + ) + + user = User.objects.get() + + self.assertRedirects(response, reverse("accounts:settings")) + + self.assertEquals(user.first_name, "First name") + self.assertEquals(user.last_name, "Last name") + + def test_linked_reddit_account(self): + self.user.reddit_refresh_token = "test" + self.user.save() + + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + self.assertNotContains(response, "Authorize Reddit account") + + +class RedditTemplateViewTestCase(TestCase): + def setUp(self): + self.user = UserFactory(email="test@test.nl", password="test") + self.client.force_login(self.user) + + self.base_url = reverse("accounts:reddit-template") + self.state = str(uuid4()) + + self.patch = patch("newsreader.news.collection.reddit.post") + self.mocked_post = self.patch.start() + + def tearDown(self): + patch.stopall() + + def test_simple(self): + response = self.client.get(self.base_url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Return to settings page") + + def test_successful_authorization(self): + self.mocked_post.return_value.json.return_value = { + "access_token": "1001010412", + "refresh_token": "134510143", + } + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Your reddit account was successfully linked.") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, "1001010412") + self.assertEquals(self.user.reddit_refresh_token, "134510143") + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), None) + + def test_error(self): + params = {"error": "Denied authorization"} + + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Denied authorization") + + def test_invalid_state(self): + cache.set(f"{self.user.email}-reddit-auth", str(uuid4())) + + params = {"code": "Valid code", "state": "Invalid state"} + + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.assertEquals(response.status_code, 200) + self.assertContains( + response, "The saved state for Reddit authorization did not match" + ) + + def test_stream_error(self): + self.mocked_post.side_effect = StreamTooManyException + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Too many requests") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) + + def test_unexpected_json(self): + self.mocked_post.return_value.json.return_value = {"message": "Happy eastern"} + + cache.set(f"{self.user.email}-reddit-auth", self.state) + + params = {"state": self.state, "code": "Valid code"} + url = f"{self.base_url}?{urlencode(params)}" + + response = self.client.get(url) + + self.mocked_post.assert_called_once() + + self.assertEquals(response.status_code, 200) + self.assertContains(response, "Access and refresh token not found in response") + + self.user.refresh_from_db() + + self.assertEquals(self.user.reddit_access_token, None) + self.assertEquals(self.user.reddit_refresh_token, None) + + self.assertEquals(cache.get(f"{self.user.email}-reddit-auth"), self.state) diff --git a/src/newsreader/accounts/tests/test_views.py b/src/newsreader/accounts/tests/test_views.py deleted file mode 100644 index d3ac77c..0000000 --- a/src/newsreader/accounts/tests/test_views.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.test import TestCase -from django.urls import reverse - -from newsreader.accounts.models import User -from newsreader.accounts.tests.factories import UserFactory - - -class UserSettingsViewTestCase(TestCase): - def setUp(self): - self.user = UserFactory(password="test") - self.client.force_login(self.user) - - def test_simple(self): - response = self.client.get(reverse("accounts:settings")) - - self.assertEquals(response.status_code, 200) - - def test_user_credential_change(self): - response = self.client.post( - reverse("accounts:settings"), - {"first_name": "First name", "last_name": "Last name"}, - ) - - user = User.objects.get() - - self.assertRedirects(response, reverse("accounts:settings")) - - self.assertEquals(user.first_name, "First name") - self.assertEquals(user.last_name, "Last name") diff --git a/src/newsreader/accounts/urls.py b/src/newsreader/accounts/urls.py index d42ae13..672cf6d 100644 --- a/src/newsreader/accounts/urls.py +++ b/src/newsreader/accounts/urls.py @@ -12,6 +12,8 @@ from newsreader.accounts.views import ( PasswordResetConfirmView, PasswordResetDoneView, PasswordResetView, + RedditTemplateView, + RedditTokenRedirectView, RegistrationClosedView, RegistrationCompleteView, RegistrationView, @@ -61,4 +63,14 @@ urlpatterns = [ name="password-change", ), path("settings/", login_required(SettingsView.as_view()), name="settings"), + path( + "settings/reddit/callback/", + login_required(RedditTemplateView.as_view()), + name="reddit-template", + ), + path( + "settings/reddit/refresh/", + login_required(RedditTokenRedirectView.as_view()), + name="reddit-refresh", + ), ] diff --git a/src/newsreader/accounts/views.py b/src/newsreader/accounts/views.py index fed60eb..4f982a9 100644 --- a/src/newsreader/accounts/views.py +++ b/src/newsreader/accounts/views.py @@ -1,13 +1,22 @@ +from django.contrib import messages from django.contrib.auth import views as django_views +from django.core.cache import cache from django.shortcuts import render from django.urls import reverse_lazy -from django.views.generic import TemplateView +from django.utils.translation import gettext as _ +from django.views.generic import RedirectView, TemplateView from django.views.generic.edit import FormView, ModelFormMixin from registration.backends.default import views as registration_views from newsreader.accounts.forms import UserSettingsForm from newsreader.accounts.models import User +from newsreader.news.collection.exceptions import StreamException +from newsreader.news.collection.reddit import ( + get_reddit_access_token, + get_reddit_authorization_url, +) +from newsreader.news.collection.tasks import RedditTokenTask class LoginView(django_views.LoginView): @@ -111,5 +120,91 @@ class SettingsView(ModelFormMixin, FormView): def get_object(self, **kwargs): return self.request.user + def get_context_data(self, **kwargs): + user = self.request.user + + reddit_authorization_url = None + reddit_refresh_url = None + reddit_task_active = cache.get(f"{user.email}-reddit-refresh") + + if ( + user.reddit_refresh_token + and not user.reddit_access_token + and not reddit_task_active + ): + reddit_refresh_url = reverse_lazy("accounts:reddit-refresh") + + if not user.reddit_refresh_token: + reddit_authorization_url = get_reddit_authorization_url(user) + + return { + **super().get_context_data(**kwargs), + "reddit_authorization_url": reddit_authorization_url, + "reddit_refresh_url": reddit_refresh_url, + } + def get_form_kwargs(self): return {**super().get_form_kwargs(), "instance": self.request.user} + + +class RedditTemplateView(TemplateView): + template_name = "accounts/views/reddit.html" + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + + error = request.GET.get("error", None) + state = request.GET.get("state", None) + code = request.GET.get("code", None) + + if error: + return self.render_to_response({**context, "error": error}) + + if not code or not state: + return self.render_to_response(context) + + cached_state = cache.get(f"{request.user.email}-reddit-auth") + + if state != cached_state: + return self.render_to_response( + { + **context, + "error": "The saved state for Reddit authorization did not match", + } + ) + + try: + access_token, refresh_token = get_reddit_access_token(code, request.user) + + return self.render_to_response( + { + **context, + "access_token": access_token, + "refresh_token": refresh_token, + } + ) + except StreamException as e: + return self.render_to_response({**context, "error": str(e)}) + except KeyError: + return self.render_to_response( + {**context, "error": "Access and refresh token not found in response"} + ) + + +class RedditTokenRedirectView(RedirectView): + url = reverse_lazy("accounts:settings") + + def get(self, request, *args, **kwargs): + response = super().get(request, *args, **kwargs) + + user = request.user + task_active = cache.get(f"{user.email}-reddit-refresh") + + if not task_active: + RedditTokenTask.delay(user.pk) + messages.success(request, _("Access token is being retrieved")) + cache.set(f"{user.email}-reddit-refresh", 1, 300) + return response + + messages.error(request, _("Unable to retrieve token")) + return response diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 62008a3..b117b4f 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -11,8 +11,8 @@ DJANGO_PROJECT_DIR = os.path.join(BASE_DIR, "src", "newsreader") # SECURITY WARNING: don"t run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ["127.0.0.1"] -INTERNAL_IPS = ["127.0.0.1"] +ALLOWED_HOSTS = ["127.0.0.1", "localhost"] +INTERNAL_IPS = ["127.0.0.1", "localhost"] # Application definition INSTALLED_APPS = [ @@ -162,7 +162,13 @@ LOGGING = { "level": "INFO", "propagate": False, }, - "celery.task": {"handlers": ["syslog", "console"], "level": "INFO"}, + "celery": {"handlers": ["syslog", "console"], "level": "INFO"}, + "celery.task": { + "handlers": ["syslog", "console"], + "level": "INFO", + "propagate": False, + }, + "newsreader": {"handlers": ["syslog", "console"], "level": "INFO"}, }, } @@ -205,6 +211,12 @@ STATICFILES_FINDERS = [ DEFAULT_FROM_EMAIL = "newsreader@rss.fudiggity.nl" +# Project settings +# Reddit integration +REDDIT_CLIENT_ID = "CLIENT_ID" +REDDIT_CLIENT_SECRET = "CLIENT_SECRET" +REDDIT_REDIRECT_URL = "http://127.0.0.1:8000/accounts/settings/reddit/callback/" + # Third party settings AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler" AXES_CACHE = "axes" diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index 4c2c480..0dee323 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -45,6 +45,11 @@ TEMPLATES = [ } ] +# Reddit integration +REDDIT_CLIENT_ID = os.environ["REDDIT_CLIENT_ID"] +REDDIT_CLIENT_SECRET = os.environ["REDDIT_CLIENT_SECRET"] +REDDIT_REDIRECT_URL = "https://rss.fudiggity.nl/settings/reddit/callback/" + # Third party settings AXES_HANDLER = "axes.handlers.database.AxesDatabaseHandler" diff --git a/src/newsreader/js/components/Card.js b/src/newsreader/js/components/Card.js index d1580a4..6346dcb 100644 --- a/src/newsreader/js/components/Card.js +++ b/src/newsreader/js/components/Card.js @@ -2,7 +2,7 @@ import React from 'react'; const Card = props => { return ( -
+
{props.header}
{props.content}
{props.footer}
diff --git a/src/newsreader/news/collection/base.py b/src/newsreader/news/collection/base.py index 710580f..f980191 100644 --- a/src/newsreader/news/collection/base.py +++ b/src/newsreader/news/collection/base.py @@ -1,18 +1,23 @@ from bs4 import BeautifulSoup from newsreader.news.collection.exceptions import StreamParseException -from newsreader.news.collection.models import CollectionRule from newsreader.news.collection.utils import fetch class Stream: + """ + Contains the data and makes it available for processing + """ + + rule = None + def __init__(self, rule): self.rule = rule def read(self): raise NotImplementedError - def parse(self, payload): + def parse(self, response): raise NotImplementedError class Meta: @@ -20,9 +25,13 @@ class Stream: class Client: + """ + Retrieves the data with streams + """ + stream = Stream - def __init__(self, rules=None): + def __init__(self, rules=[]): self.rules = rules if rules else CollectionRule.objects.enabled() def __enter__(self): @@ -39,7 +48,12 @@ class Client: class Builder: + """ + Creates the collected posts + """ + instances = [] + stream = None def __init__(self, stream): self.stream = stream @@ -62,6 +76,10 @@ class Builder: class Collector: + """ + Glue between client, streams and builder + """ + client = None builder = None diff --git a/src/newsreader/news/collection/choices.py b/src/newsreader/news/collection/choices.py new file mode 100644 index 0000000..65f7ef5 --- /dev/null +++ b/src/newsreader/news/collection/choices.py @@ -0,0 +1,7 @@ +from django.db.models import TextChoices +from django.utils.translation import gettext as _ + + +class RuleTypeChoices(TextChoices): + feed = "feed", _("Feed") + subreddit = "subreddit", _("Subreddit") diff --git a/src/newsreader/news/collection/exceptions.py b/src/newsreader/news/collection/exceptions.py index e636638..e002b43 100644 --- a/src/newsreader/news/collection/exceptions.py +++ b/src/newsreader/news/collection/exceptions.py @@ -1,7 +1,8 @@ class StreamException(Exception): message = "Stream exception" - def __init__(self, message=None): + def __init__(self, response=None, message=None): + self.response = response self.message = message if message else self.message def __str__(self): @@ -28,5 +29,9 @@ class StreamParseException(StreamException): message = "Stream could not be parsed" -class StreamConnectionError(StreamException): +class StreamConnectionException(StreamException): message = "A connection to the stream could not be made" + + +class StreamTooManyException(StreamException): + message = "Too many requests" diff --git a/src/newsreader/news/collection/feed.py b/src/newsreader/news/collection/feed.py index e237713..8018bb5 100644 --- a/src/newsreader/news/collection/feed.py +++ b/src/newsreader/news/collection/feed.py @@ -4,8 +4,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import timedelta from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist -from django.db.models.fields import CharField, TextField -from django.template.defaultfilters import truncatechars from django.utils import timezone import bleach @@ -14,6 +12,7 @@ import pytz from feedparser import parse from newsreader.news.collection.base import Builder, Client, Collector, Stream +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.constants import ( WHITELISTED_ATTRIBUTES, WHITELISTED_TAGS, @@ -25,7 +24,12 @@ from newsreader.news.collection.exceptions import ( StreamParseException, StreamTimeOutException, ) -from newsreader.news.collection.utils import build_publication_date, fetch +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.utils import ( + build_publication_date, + fetch, + truncate_text, +) from newsreader.news.core.models import Post @@ -37,10 +41,13 @@ class FeedBuilder(Builder): def __enter__(self): _, stream = self.stream + self.instances = [] self.existing_posts = { post.remote_identifier: post - for post in Post.objects.filter(rule=stream.rule) + for post in Post.objects.filter( + rule=stream.rule, rule__type=RuleTypeChoices.feed + ) } return super().__enter__() @@ -73,7 +80,7 @@ class FeedBuilder(Builder): if not field in entry: continue - value = self.truncate_text(model_field, entry[field]) + value = truncate_text(Post, model_field, entry[field]) if field == "published_parsed": data[model_field] = build_publication_date(value, tz) @@ -103,21 +110,6 @@ class FeedBuilder(Builder): strip_comments=True, ) - def truncate_text(self, field_name, value): - field = Post._meta.get_field(field_name) - max_length = field.max_length - cls = type(field) - - if not value or not max_length: - return value - elif not bool(issubclass(cls, CharField) or issubclass(cls, TextField)): - return value - - if len(value) > max_length: - return truncatechars(value, max_length) - - return value - def get_content(self, items): content = "\n ".join([item.get("value") for item in items]) return self.sanitize_fragment(content) @@ -129,21 +121,29 @@ class FeedBuilder(Builder): class FeedStream(Stream): def read(self): - url = self.rule.url - response = fetch(url) + response = fetch(self.rule.url) - return (self.parse(response.content), self) + return self.parse(response), self - def parse(self, payload): + def parse(self, response): try: - return parse(payload) + return parse(response.content) except TypeError as e: - raise StreamParseException("Could not parse feed") from e + message = "Could not parse feed" + raise StreamParseException(response=response, message=message) from e class FeedClient(Client): stream = FeedStream + def __init__(self, rules=[]): + if rules: + self.rules = rules + else: + self.rules = CollectionRule.objects.filter( + enabled=True, type=RuleTypeChoices.feed + ) + def __enter__(self): streams = [self.stream(rule) for rule in self.rules] diff --git a/src/newsreader/news/collection/forms.py b/src/newsreader/news/collection/forms.py index 7e5fc97..1d9b996 100644 --- a/src/newsreader/news/collection/forms.py +++ b/src/newsreader/news/collection/forms.py @@ -1,18 +1,29 @@ from django import forms +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ import pytz +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule from newsreader.news.core.models import Category +def get_reddit_help_text(): + return mark_safe( + "Only subreddits are supported. For example: " + "https://www.reddit.com/r/aww" + ) + + class CollectionRuleForm(forms.ModelForm): category = forms.ModelChoiceField(required=False, queryset=Category.objects.all()) timezone = forms.ChoiceField( widget=forms.Select(attrs={"size": len(pytz.all_timezones)}), choices=((timezone, timezone) for timezone in pytz.all_timezones), help_text=_("The timezone which the feed uses"), + initial=pytz.utc, ) def __init__(self, *args, **kwargs): @@ -20,8 +31,7 @@ class CollectionRuleForm(forms.ModelForm): super().__init__(*args, **kwargs) - 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): instance = super().save(commit=False) @@ -49,6 +59,32 @@ class CollectionRuleBulkForm(forms.Form): self.fields["rules"].queryset = CollectionRule.objects.filter(user=user) +class SubRedditRuleForm(CollectionRuleForm): + url = forms.URLField(max_length=1024, help_text=get_reddit_help_text) + + timezone = None + + def save(self, commit=True): + instance = super().save(commit=False) + + instance.type = RuleTypeChoices.subreddit + instance.timezone = str(pytz.utc) + instance.user = self.user + + if not instance.url.endswith(".json"): + instance.url = f"{instance.url}.json" + + if commit: + instance.save() + self.save_m2m() + + return instance + + class Meta: + model = CollectionRule + fields = ("name", "url", "favicon", "category") + + class OPMLImportForm(forms.Form): file = forms.FileField(allow_empty_file=False) skip_existing = forms.BooleanField(initial=False, required=False) diff --git a/src/newsreader/news/collection/migrations/0008_collectionrule_type.py b/src/newsreader/news/collection/migrations/0008_collectionrule_type.py new file mode 100644 index 0000000..bb8975d --- /dev/null +++ b/src/newsreader/news/collection/migrations/0008_collectionrule_type.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.5 on 2020-06-03 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("collection", "0007_collectionrule_enabled")] + + operations = [ + migrations.AddField( + model_name="collectionrule", + name="type", + field=models.CharField( + choices=[("feed", "Feed"), ("subreddit", "Subreddit")], + default="feed", + max_length=20, + ), + ) + ] diff --git a/src/newsreader/news/collection/models.py b/src/newsreader/news/collection/models.py index cc22f8a..35841ba 100644 --- a/src/newsreader/news/collection/models.py +++ b/src/newsreader/news/collection/models.py @@ -1,9 +1,11 @@ from django.db import models +from django.urls import reverse from django.utils.translation import gettext as _ import pytz from newsreader.core.models import TimeStampedModel +from newsreader.news.collection.choices import RuleTypeChoices class CollectionRuleQuerySet(models.QuerySet): @@ -13,6 +15,9 @@ class CollectionRuleQuerySet(models.QuerySet): class CollectionRule(TimeStampedModel): name = models.CharField(max_length=100) + type = models.CharField( + max_length=20, choices=RuleTypeChoices.choices, default=RuleTypeChoices.feed + ) url = models.URLField(max_length=1024) website_url = models.URLField( @@ -23,7 +28,7 @@ class CollectionRule(TimeStampedModel): timezone = models.CharField( choices=((timezone, timezone) for timezone in pytz.all_timezones), max_length=100, - default="UTC", + default=str(pytz.utc), ) category = models.ForeignKey( @@ -38,7 +43,9 @@ class CollectionRule(TimeStampedModel): last_suceeded = models.DateTimeField(blank=True, null=True) succeeded = models.BooleanField(default=False) + error = models.CharField(max_length=1024, blank=True, null=True) + enabled = models.BooleanField( default=True, help_text=_("Wether or not to collect items from this feed") ) @@ -54,3 +61,10 @@ class CollectionRule(TimeStampedModel): def __str__(self): return self.name + + @property + def update_url(self): + if self.type == RuleTypeChoices.subreddit: + return reverse("news:collection:subreddit-update", kwargs={"pk": self.pk}) + + return reverse("news:collection:rule-update", kwargs={"pk": self.pk}) diff --git a/src/newsreader/news/collection/reddit.py b/src/newsreader/news/collection/reddit.py new file mode 100644 index 0000000..2bb7bd9 --- /dev/null +++ b/src/newsreader/news/collection/reddit.py @@ -0,0 +1,307 @@ +import logging + +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timedelta +from html import unescape +from json.decoder import JSONDecodeError +from urllib.parse import urlencode +from uuid import uuid4 + +from django.conf import settings +from django.core.cache import cache +from django.utils import timezone + +import bleach +import pytz +import requests + +from newsreader.news.collection.base import Builder, Client, Collector, Stream +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.constants import ( + WHITELISTED_ATTRIBUTES, + WHITELISTED_TAGS, +) +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamParseException, + StreamTooManyException, +) +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.tasks import RedditTokenTask +from newsreader.news.collection.utils import fetch, post, truncate_text +from newsreader.news.core.models import Post + + +logger = logging.getLogger(__name__) + + +REDDIT_URL = "https://www.reddit.com" +REDDIT_API_URL = "https://oauth.reddit.com" + +RATE_LIMIT = 60 +RATE_LIMIT_DURATION = timedelta(seconds=60) + + +def get_reddit_authorization_url(user): + state = str(uuid4()) + cache.set(f"{user.email}-reddit-auth", state) + + params = { + "client_id": settings.REDDIT_CLIENT_ID, + "redirect_uri": settings.REDDIT_REDIRECT_URL, + "state": state, + "response_type": "code", + "duration": "permanent", + "scope": "identity,mysubreddits,save,read", + } + + authorization_url = f"{REDDIT_URL}/api/v1/authorize" + return f"{authorization_url}?{urlencode(params)}" + + +def get_reddit_access_token(code, user): + client_auth = requests.auth.HTTPBasicAuth( + settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET + ) + + response = post( + f"{REDDIT_URL}/api/v1/access_token", + data={ + "redirect_uri": settings.REDDIT_REDIRECT_URL, + "grant_type": "authorization_code", + "code": code, + }, + auth=client_auth, + ) + + response_data = response.json() + + user.reddit_access_token = response_data["access_token"] + user.reddit_refresh_token = response_data["refresh_token"] + user.save() + + cache.delete(f"{user.email}-reddit-auth") + + return response_data["access_token"], response_data["refresh_token"] + + +class RedditBuilder(Builder): + def __enter__(self): + _, stream = self.stream + + self.instances = [] + self.existing_posts = { + post.remote_identifier: post + for post in Post.objects.filter( + rule=stream.rule, rule__type=RuleTypeChoices.subreddit + ) + } + + return super().__enter__() + + def create_posts(self, stream): + data, stream = stream + posts = [] + + if not "data" in data or not "children" in data["data"]: + return + + posts = data["data"]["children"] + self.instances = self.build(posts, stream.rule) + + def build(self, posts, rule): + for post in posts: + if not "data" in post: + continue + + remote_identifier = post["data"]["id"] + title = truncate_text(Post, "title", post["data"]["title"]) + author = truncate_text(Post, "author", post["data"]["author"]) + url_fragment = f"{post['data']['permalink']}" + + uncleaned_body = post["data"]["selftext_html"] + unescaped_body = unescape(uncleaned_body) if uncleaned_body else "" + body = ( + bleach.clean( + unescaped_body, + tags=WHITELISTED_TAGS, + attributes=WHITELISTED_ATTRIBUTES, + strip=True, + strip_comments=True, + ) + if unescaped_body + else "" + ) + + try: + parsed_date = datetime.fromtimestamp(post["data"]["created_utc"]) + created_date = pytz.utc.localize(parsed_date) + except (OverflowError, OSError): + logging.warning(f"Failed parsing timestamp from {url_fragment}") + created_date = timezone.now() + + data = { + "remote_identifier": remote_identifier, + "title": title, + "body": body, + "author": author, + "url": f"{REDDIT_URL}{url_fragment}", + "publication_date": created_date, + "rule": rule, + } + + if remote_identifier in self.existing_posts: + existing_post = self.existing_posts[remote_identifier] + + if created_date > existing_post.publication_date: + for key, value in data.items(): + setattr(existing_post, key, value) + + yield existing_post + continue + + yield Post(**data) + + def save(self): + for post in self.instances: + post.save() + + +class RedditScheduler: + max_amount = RATE_LIMIT + max_user_amount = RATE_LIMIT / 4 + + def __init__(self, subreddits=[]): + if not subreddits: + self.subreddits = CollectionRule.objects.filter( + type=RuleTypeChoices.subreddit, + user__reddit_access_token__isnull=False, + user__reddit_refresh_token__isnull=False, + enabled=True, + ).order_by("last_suceeded")[:200] + else: + self.subreddits = subreddits + + def get_scheduled_rules(self): + rule_mapping = {} + current_amount = 0 + + for subreddit in self.subreddits: + user_pk = subreddit.user.pk + + if current_amount == self.max_amount: + break + + if user_pk in rule_mapping: + max_amount_reached = len(rule_mapping[user_pk]) == self.max_user_amount + + if max_amount_reached: + continue + + rule_mapping[user_pk].append(subreddit) + current_amount += 1 + + continue + + rule_mapping[user_pk] = [subreddit] + current_amount += 1 + + return list(rule_mapping.values()) + + +class RedditStream(Stream): + headers = {} + user = None + + def __init__(self, rule): + super().__init__(rule) + + self.user = self.rule.user + self.headers = { + f"Authorization": f"bearer {self.rule.user.reddit_access_token}" + } + + def read(self): + response = fetch(self.rule.url, headers=self.headers) + + return self.parse(response), self + + def parse(self, response): + try: + return response.json() + except JSONDecodeError as e: + raise StreamParseException( + response=response, message=f"Failed parsing json" + ) from e + + +class RedditClient(Client): + stream = RedditStream + + def __init__(self, rules=[]): + self.rules = rules + + def __enter__(self): + streams = [[self.stream(rule) for rule in batch] for batch in self.rules] + rate_limitted = False + + with ThreadPoolExecutor(max_workers=10) as executor: + for batch in streams: + futures = {executor.submit(stream.read): stream for stream in batch} + + if rate_limitted: + break + + 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 StreamDeniedException as e: + logger.exception( + f"Access token expired for user {stream.user.pk}" + ) + + stream.rule.user.reddit_access_token = None + stream.rule.user.save() + + self.set_rule_error(stream.rule, e) + + RedditTokenTask.delay(stream.rule.user.pk) + + break + except StreamTooManyException as e: + logger.exception("Ratelimit hit, aborting batched subreddits") + + self.set_rule_error(stream.rule, e) + + rate_limitted = True + break + except StreamException as e: + logger.exception( + "Stream failed reading content from " f"{stream.rule.url}" + ) + + self.set_rule_error(stream.rule, e) + + continue + finally: + stream.rule.save() + + def set_rule_error(self, rule, exception): + length = rule._meta.get_field("error").max_length + + rule.error = exception.message[-length:] + rule.succeeded = False + + +class RedditCollector(Collector): + builder = RedditBuilder + client = RedditClient diff --git a/src/newsreader/news/collection/response_handler.py b/src/newsreader/news/collection/response_handler.py index 3a16376..2cd785d 100644 --- a/src/newsreader/news/collection/response_handler.py +++ b/src/newsreader/news/collection/response_handler.py @@ -1,12 +1,13 @@ from requests.exceptions import ConnectionError as RequestConnectionError from newsreader.news.collection.exceptions import ( - StreamConnectionError, + StreamConnectionException, StreamDeniedException, StreamException, StreamForbiddenException, StreamNotFoundException, StreamTimeOutException, + StreamTooManyException, ) @@ -16,9 +17,10 @@ class ResponseHandler: 401: StreamDeniedException, 403: StreamForbiddenException, 408: StreamTimeOutException, + 429: StreamTooManyException, } - exception_mapping = {RequestConnectionError: StreamConnectionError} + exception_mapping = {RequestConnectionError: StreamConnectionException} def __enter__(self): return self @@ -27,16 +29,20 @@ class ResponseHandler: status_code = response.status_code if status_code in self.status_code_mapping: - raise self.status_code_mapping[status_code] + exception = self.status_code_mapping[status_code] + raise exception(response) + + def map_exception(self, exception): + if isinstance(exception, StreamException): + raise exception - def handle_exception(self, exception): try: stream_exception = self.exception_mapping[type(exception)] except KeyError: stream_exception = StreamException message = getattr(exception, "message", str(exception)) - raise stream_exception(message=message) from exception + raise stream_exception(exception.response, message=message) from exception def __exit__(self, *args, **kwargs): pass diff --git a/src/newsreader/news/collection/tasks.py b/src/newsreader/news/collection/tasks.py index dab94d4..d368a5c 100644 --- a/src/newsreader/news/collection/tasks.py +++ b/src/newsreader/news/collection/tasks.py @@ -1,11 +1,15 @@ +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist +import requests + from celery.exceptions import Reject from celery.utils.log import get_task_logger from newsreader.accounts.models import User from newsreader.celery import app from newsreader.news.collection.feed import FeedCollector +from newsreader.news.collection.utils import post from newsreader.utils.celery import MemCacheLock @@ -13,7 +17,7 @@ logger = get_task_logger(__name__) class FeedTask(app.Task): - name = "newsreader.news.collection.tasks.FeedTask" + name = "FeedTask" ignore_result = True def run(self, user_pk): @@ -41,4 +45,74 @@ class FeedTask(app.Task): raise Reject(reason="Task already running", requeue=False) +class RedditTask(app.Task): + name = "RedditTask" + ignore_result = True + + def run(self): + from newsreader.news.collection.reddit import RedditCollector, RedditScheduler + + with MemCacheLock("reddit-task", self.app.oid) as acquired: + if acquired: + logger.info(f"Running reddit task") + + scheduler = RedditScheduler() + subreddits = scheduler.get_scheduled_rules() + + collector = RedditCollector() + collector.collect(rules=subreddits) + else: + logger.warning(f"Cancelling task due to existing lock") + + raise Reject(reason="Task already running", requeue=False) + + +class RedditTokenTask(app.Task): + name = "RedditTokenTask" + ignore_result = True + + def run(self, user_pk): + from newsreader.news.collection.reddit import REDDIT_URL + + try: + user = User.objects.get(pk=user_pk) + except ObjectDoesNotExist: + message = f"User {user_pk} does not exist" + logger.exception(message) + + raise Reject(reason=message, requeue=False) + + if not user.reddit_refresh_token: + raise Reject(reason=f"User {user_pk} has no refresh token", requeue=False) + + client_auth = requests.auth.HTTPBasicAuth( + settings.REDDIT_CLIENT_ID, settings.REDDIT_CLIENT_SECRET + ) + + try: + response = post( + f"{REDDIT_URL}/api/v1/access_token", + data={ + "grant_type": "refresh_token", + "refresh_token": user.reddit_refresh_token, + }, + auth=client_auth, + ) + except StreamException: + logger.exception( + f"Failed refreshing reddit access token for user {user_pk}" + ) + + user.reddit_refresh_token = None + user.save() + return + + response_data = response.json() + + user.reddit_access_token = response_data["access_token"] + user.save() + + FeedTask = app.register_task(FeedTask()) +RedditTask = app.register_task(RedditTask()) +RedditTokenTask = app.register_task(RedditTokenTask()) diff --git a/src/newsreader/news/collection/templates/news/collection/views/rules.html b/src/newsreader/news/collection/templates/news/collection/views/rules.html index 1da7c4d..b8ab514 100644 --- a/src/newsreader/news/collection/templates/news/collection/views/rules.html +++ b/src/newsreader/news/collection/templates/news/collection/views/rules.html @@ -15,6 +15,7 @@ @@ -48,7 +49,7 @@ {{ rule.succeeded }} {{ rule.enabled }} - + {% endfor %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/subreddit-create.html b/src/newsreader/news/collection/templates/news/collection/views/subreddit-create.html new file mode 100644 index 0000000..6250e4e --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/subreddit-create.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
+ {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Add a subreddit" cancel_url=cancel_url confirm_text="Add subrredit" %} +
+{% endblock %} diff --git a/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html b/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html new file mode 100644 index 0000000..9ea7d05 --- /dev/null +++ b/src/newsreader/news/collection/templates/news/collection/views/subreddit-update.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load static %} + +{% block content %} +
+ {% url "news:collection:rules" as cancel_url %} + {% include "components/form/form.html" with form=form title="Update subreddit" cancel_url=cancel_url confirm_text="Save subreddit" %} +
+{% endblock %} diff --git a/src/newsreader/news/collection/tests/factories.py b/src/newsreader/news/collection/tests/factories.py index 678e0f4..fdf786f 100644 --- a/src/newsreader/news/collection/tests/factories.py +++ b/src/newsreader/news/collection/tests/factories.py @@ -1,7 +1,9 @@ import factory from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.reddit import REDDIT_URL class CollectionRuleFactory(factory.django.DjangoModelFactory): @@ -17,3 +19,12 @@ class CollectionRuleFactory(factory.django.DjangoModelFactory): class Meta: model = CollectionRule + + +class FeedFactory(CollectionRuleFactory): + type = RuleTypeChoices.feed + + +class SubredditFactory(CollectionRuleFactory): + type = RuleTypeChoices.subreddit + website_url = REDDIT_URL diff --git a/src/newsreader/news/collection/tests/feed/builder/tests.py b/src/newsreader/news/collection/tests/feed/builder/tests.py index cfafa4f..7069f96 100644 --- a/src/newsreader/news/collection/tests/feed/builder/tests.py +++ b/src/newsreader/news/collection/tests/feed/builder/tests.py @@ -9,7 +9,7 @@ import pytz from freezegun import freeze_time from newsreader.news.collection.feed import FeedBuilder -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory @@ -23,7 +23,7 @@ class FeedBuilderTestCase(TestCase): def test_basic_entry(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((simple_mock, mock_stream)) as builder: @@ -54,7 +54,7 @@ class FeedBuilderTestCase(TestCase): def test_multiple_entries(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((multiple_mock, mock_stream)) as builder: @@ -115,7 +115,7 @@ class FeedBuilderTestCase(TestCase): def test_entries_without_remote_identifier(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_identifier, mock_stream)) as builder: @@ -154,7 +154,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_publication_date(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_publish_date, mock_stream)) as builder: @@ -186,7 +186,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_url(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_url, mock_stream)) as builder: @@ -212,7 +212,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_body(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_body, mock_stream)) as builder: @@ -246,7 +246,7 @@ class FeedBuilderTestCase(TestCase): def test_entry_without_author(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_author, mock_stream)) as builder: @@ -274,7 +274,7 @@ class FeedBuilderTestCase(TestCase): def test_empty_entries(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_without_entries, mock_stream)) as builder: @@ -284,7 +284,7 @@ class FeedBuilderTestCase(TestCase): def test_update_entries(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) existing_first_post = PostFactory.create( @@ -314,7 +314,7 @@ class FeedBuilderTestCase(TestCase): def test_html_sanitizing(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_html, mock_stream)) as builder: @@ -336,7 +336,7 @@ class FeedBuilderTestCase(TestCase): def test_long_author_text_is_truncated(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_long_author, mock_stream)) as builder: @@ -350,7 +350,7 @@ class FeedBuilderTestCase(TestCase): def test_long_title_text_is_truncated(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_long_title, mock_stream)) as builder: @@ -364,7 +364,7 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_prioritized_if_longer(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_longer_content_detail, mock_stream)) as builder: @@ -381,7 +381,7 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_not_prioritized_if_shorter(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_shorter_content_detail, mock_stream)) as builder: @@ -397,7 +397,7 @@ class FeedBuilderTestCase(TestCase): def test_content_detail_is_concatinated(self): builder = FeedBuilder - rule = CollectionRuleFactory() + rule = FeedFactory() mock_stream = MagicMock(rule=rule) with builder((mock_with_multiple_content_detail, mock_stream)) as builder: diff --git a/src/newsreader/news/collection/tests/feed/client/tests.py b/src/newsreader/news/collection/tests/feed/client/tests.py index dd3c1e4..59b5f65 100644 --- a/src/newsreader/news/collection/tests/feed/client/tests.py +++ b/src/newsreader/news/collection/tests/feed/client/tests.py @@ -11,7 +11,7 @@ from newsreader.news.collection.exceptions import ( StreamTimeOutException, ) from newsreader.news.collection.feed import FeedClient -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from .mocks import simple_mock @@ -27,8 +27,9 @@ class FeedClientTestCase(TestCase): patch.stopall() def test_client_retrieves_single_rules(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) + self.mocked_read.return_value = (simple_mock, mock_stream) with FeedClient([rule]) as client: @@ -39,9 +40,10 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamException("Stream exception") + + self.mocked_read.side_effect = StreamException(message="Stream exception") with FeedClient([rule]) as client: for data, stream in client: @@ -52,9 +54,12 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_not_found_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamNotFoundException("Stream not found") + + self.mocked_read.side_effect = StreamNotFoundException( + message="Stream not found" + ) with FeedClient([rule]) as client: for data, stream in client: @@ -65,9 +70,10 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_denied_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamDeniedException("Stream denied") + + self.mocked_read.side_effect = StreamDeniedException(message="Stream denied") with FeedClient([rule]) as client: for data, stream in client: @@ -78,9 +84,12 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_timed_out(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamTimeOutException("Stream timed out") + + self.mocked_read.side_effect = StreamTimeOutException( + message="Stream timed out" + ) with FeedClient([rule]) as client: for data, stream in client: @@ -91,22 +100,12 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_stream_parse_exception(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamParseException("Stream has wrong contents") - with FeedClient([rule]) as client: - for data, stream in client: - self.assertEquals(data, {"entries": []}) - self.assertEquals(stream.rule.error, "Stream has wrong contents") - self.assertEquals(stream.rule.succeeded, False) - - self.mocked_read.assert_called_once_with() - - def test_client_catches_stream_parse_exception(self): - rule = CollectionRuleFactory.create() - mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamParseException("Stream has wrong contents") + self.mocked_read.side_effect = StreamParseException( + message="Stream has wrong contents" + ) with FeedClient([rule]) as client: for data, stream in client: @@ -117,9 +116,10 @@ class FeedClientTestCase(TestCase): self.mocked_read.assert_called_once_with() def test_client_catches_long_exception_text(self): - rule = CollectionRuleFactory.create() + rule = FeedFactory.create() mock_stream = MagicMock(rule=rule) - self.mocked_read.side_effect = StreamParseException(words(1000)) + + self.mocked_read.side_effect = StreamParseException(message=words(1000)) with FeedClient([rule]) as client: for data, stream in client: diff --git a/src/newsreader/news/collection/tests/feed/collector/tests.py b/src/newsreader/news/collection/tests/feed/collector/tests.py index 0506783..b0fc7cf 100644 --- a/src/newsreader/news/collection/tests/feed/collector/tests.py +++ b/src/newsreader/news/collection/tests/feed/collector/tests.py @@ -18,7 +18,7 @@ from newsreader.news.collection.exceptions import ( StreamTimeOutException, ) from newsreader.news.collection.feed import FeedCollector -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.collection.utils import build_publication_date from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory @@ -42,7 +42,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-10-30 12:30:00") def test_simple_batch(self): self.mocked_parse.return_value = multiple_mock - rule = CollectionRuleFactory() + rule = FeedFactory() collector = FeedCollector() collector.collect() @@ -58,7 +58,7 @@ class FeedCollectorTestCase(TestCase): def test_emtpy_batch(self): self.mocked_fetch.return_value = MagicMock() self.mocked_parse.return_value = empty_mock - rule = CollectionRuleFactory() + rule = FeedFactory() collector = FeedCollector() collector.collect() @@ -72,7 +72,7 @@ class FeedCollectorTestCase(TestCase): def test_not_found(self): self.mocked_fetch.side_effect = StreamNotFoundException - rule = CollectionRuleFactory() + rule = FeedFactory() collector = FeedCollector() collector.collect() @@ -88,7 +88,7 @@ class FeedCollectorTestCase(TestCase): last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) - rule = CollectionRuleFactory(last_suceeded=last_suceeded) + rule = FeedFactory(last_suceeded=last_suceeded) collector = FeedCollector() collector.collect() @@ -105,7 +105,7 @@ class FeedCollectorTestCase(TestCase): last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) - rule = CollectionRuleFactory(last_suceeded=last_suceeded) + rule = FeedFactory(last_suceeded=last_suceeded) collector = FeedCollector() collector.collect() @@ -122,7 +122,7 @@ class FeedCollectorTestCase(TestCase): last_suceeded = timezone.make_aware( datetime.combine(date=date(2019, 10, 30), time=time(12, 30)) ) - rule = CollectionRuleFactory(last_suceeded=last_suceeded) + rule = FeedFactory(last_suceeded=last_suceeded) collector = FeedCollector() collector.collect() @@ -137,7 +137,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-10-30 12:30:00") def test_duplicates(self): self.mocked_parse.return_value = duplicate_mock - rule = CollectionRuleFactory() + rule = FeedFactory() aware_datetime = build_publication_date( struct_time((2019, 5, 20, 16, 7, 37, 0, 140, 0)), pytz.utc @@ -192,7 +192,7 @@ class FeedCollectorTestCase(TestCase): @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() + rule = FeedFactory() first_post = PostFactory( remote_identifier="https://www.bbc.co.uk/news/world-us-canada-48338168", @@ -248,10 +248,7 @@ class FeedCollectorTestCase(TestCase): @freeze_time("2019-02-22 12:30:00") def test_disabled_rules(self): - rules = ( - CollectionRuleFactory(enabled=False), - CollectionRuleFactory(enabled=True), - ) + rules = (FeedFactory(enabled=False), FeedFactory(enabled=True)) self.mocked_parse.return_value = multiple_mock diff --git a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py index 109491b..18a6c6c 100644 --- a/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py +++ b/src/newsreader/news/collection/tests/feed/duplicate_handler/tests.py @@ -6,7 +6,7 @@ from django.utils import timezone from freezegun import freeze_time from newsreader.news.collection.feed import FeedDuplicateHandler -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory from newsreader.news.core.models import Post from newsreader.news.core.tests.factories import PostFactory @@ -17,7 +17,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.maxDiff = None def test_duplicate_entries_with_remote_identifiers(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory.create( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule @@ -52,7 +52,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_with_different_remote_identifiers(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", @@ -98,7 +98,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_in_recent_database(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", @@ -145,7 +145,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_multiple_existing_entries_with_identifier(self): - rule = CollectionRuleFactory() + rule = FeedFactory() PostFactory.create_batch( remote_identifier="28f79ae4-8f9a-11e9-b143-00163ef6bee7", rule=rule, size=5 @@ -187,7 +187,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_outside_time_slot(self): - rule = CollectionRuleFactory() + rule = FeedFactory() existing_post = PostFactory( url="https://www.bbc.co.uk/news/uk-england-birmingham-48339080", @@ -234,7 +234,7 @@ class FeedDuplicateHandlerTestCase(TestCase): self.assertEquals(post.read, False) def test_duplicate_entries_in_collected_entries(self): - rule = CollectionRuleFactory() + rule = FeedFactory() post_1 = PostFactory.build( title="title got updated", body="body", diff --git a/src/newsreader/news/collection/tests/feed/stream/mocks.py b/src/newsreader/news/collection/tests/feed/stream/mocks.py index 7dfeba6..4218355 100644 --- a/src/newsreader/news/collection/tests/feed/stream/mocks.py +++ b/src/newsreader/news/collection/tests/feed/stream/mocks.py @@ -1,59 +1,174 @@ from time import struct_time -simple_mock = { - "bozo": 1, +simple_mock = bytes( + """ + + + + <![CDATA[BBC News - Home]]> + + https://www.bbc.co.uk/news/ + + https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif + BBC News - Home + https://www.bbc.co.uk/news/ + + RSS for Node + Sun, 12 Jul 2020 17:21:20 GMT + + + 15 + + <![CDATA[Coronavirus: I trust people's sense on face masks - Gove]]> + + https://www.bbc.co.uk/news/uk-53381000 + https://www.bbc.co.uk/news/uk-53381000 + Sun, 12 Jul 2020 16:15:03 GMT + + + <![CDATA[Farm outbreak leads 200 to self isolate ]]> + + https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802 + https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802 + Sun, 12 Jul 2020 17:19:31 GMT + + + <![CDATA[English Channel search operation after migrant crossings]]> + + https://www.bbc.co.uk/news/uk-53382563 + https://www.bbc.co.uk/news/uk-53382563 + Sun, 12 Jul 2020 15:47:17 GMT + + + """, + "utf-8", +) + + +simple_mock_parsed = { + "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", + "id": "https://www.bbc.co.uk/news/uk-53381000", + "link": "https://www.bbc.co.uk/news/uk-53381000", "links": [ { - "href": "https://www.bbc.co.uk/news/world-us-canada-48338168", + "href": "https://www.bbc.co.uk/news/uk-53381000", "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.", + "published": "Sun, 12 Jul 2020 16:15:03 GMT", + "published_parsed": struct_time((2020, 7, 12, 16, 15, 3, 6, 194, 0)), + "summary": "Minister Michael Gove says he does not think face " + "coverings should be mandatory in shops in England.", "summary_detail": { - "base": "http://feeds.bbci.co.uk/news/rss.xml", + "base": "", "language": None, "type": "text/html", - "value": "Foreign Minister Mohammad Javad " - "Zarif says the US president should " - "try showing Iranians some " - "respect.", + "value": "Minister Michael Gove says he does " + "not think face coverings should be " + "mandatory in shops in England.", }, - "title": "Trump's 'genocidal taunts' will not end Iran - Zarif", + "title": "Coronavirus: I trust people's sense on face masks - " "Gove", "title_detail": { - "base": "http://feeds.bbci.co.uk/news/rss.xml", + "base": "", "language": None, "type": "text/plain", - "value": "Trump's 'genocidal taunts' will not " "end Iran - Zarif", + "value": "Coronavirus: I trust people's sense " "on face masks - Gove", }, - } + }, + { + "guidislink": False, + "id": "https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802", + "link": "https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-england-hereford-worcester-53381802", + "rel": "alternate", + "type": "text/html", + } + ], + "published": "Sun, 12 Jul 2020 17:19:31 GMT", + "published_parsed": struct_time((2020, 7, 12, 17, 19, 31, 6, 194, 0)), + "summary": "Up to 200 vegetable pickers and packers will remain " + "on the farm in Herefordshire while isolating.", + "summary_detail": { + "base": "", + "language": None, + "type": "text/html", + "value": "Up to 200 vegetable pickers and " + "packers will remain on the farm in " + "Herefordshire while isolating.", + }, + "title": "Farm outbreak leads 200 to self isolate", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "Farm outbreak leads 200 to self " "isolate", + }, + }, + { + "guidislink": False, + "id": "https://www.bbc.co.uk/news/uk-53382563", + "link": "https://www.bbc.co.uk/news/uk-53382563", + "links": [ + { + "href": "https://www.bbc.co.uk/news/uk-53382563", + "rel": "alternate", + "type": "text/html", + } + ], + "published": "Sun, 12 Jul 2020 15:47:17 GMT", + "published_parsed": struct_time((2020, 7, 12, 15, 47, 17, 6, 194, 0)), + "summary": "Several boats are spotted as the home secretary " + "visits France for talks on tackling people " + "smuggling.", + "summary_detail": { + "base": "", + "language": None, + "type": "text/html", + "value": "Several boats are spotted as the " + "home secretary visits France for " + "talks on tackling people " + "smuggling.", + }, + "title": "English Channel search operation after migrant " "crossings", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "English Channel search operation " "after migrant crossings", + }, + }, ], "feed": { + "generator": "RSS for Node", + "generator_detail": {"name": "RSS for Node"}, "image": { "href": "https://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif", "link": "https://www.bbc.co.uk/news/", + "links": [ + { + "href": "https://www.bbc.co.uk/news/", + "rel": "alternate", + "type": "text/html", + } + ], "title": "BBC News - Home", - "language": "en-gb", - "link": "https://www.bbc.co.uk/news/", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "BBC News - Home", + }, }, + "language": "en-gb", + "link": "https://www.bbc.co.uk/news/", "links": [ { "href": "https://www.bbc.co.uk/news/", @@ -61,9 +176,41 @@ simple_mock = { "type": "text/html", } ], + "rights": "Copyright: (C) British Broadcasting Corporation, see " + "http://news.bbc.co.uk/2/hi/help/rss/4498287.stm for terms " + "and conditions of reuse.", + "rights_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "Copyright: (C) British Broadcasting " + "Corporation, see " + "http://news.bbc.co.uk/2/hi/help/rss/4498287.stm " + "for terms and conditions of reuse.", + }, + "subtitle": "BBC News - Home", + "subtitle_detail": { + "base": "", + "language": None, + "type": "text/html", + "value": "BBC News - Home", + }, "title": "BBC News - Home", + "title_detail": { + "base": "", + "language": None, + "type": "text/plain", + "value": "BBC News - Home", + }, + "ttl": "15", + "updated": "Sun, 12 Jul 2020 17:21:20 GMT", + "updated_parsed": struct_time((2020, 7, 12, 17, 21, 20, 6, 194, 0)), + }, + "namespaces": { + "": "http://www.w3.org/2005/Atom", + "content": "http://purl.org/rss/1.0/modules/content/", + "dc": "http://purl.org/dc/elements/1.1/", + "media": "http://search.yahoo.com/mrss/", }, - "href": "http://feeds.bbci.co.uk/news/rss.xml", - "status": 200, "version": "rss20", } diff --git a/src/newsreader/news/collection/tests/feed/stream/tests.py b/src/newsreader/news/collection/tests/feed/stream/tests.py index 7c0f203..82a09a3 100644 --- a/src/newsreader/news/collection/tests/feed/stream/tests.py +++ b/src/newsreader/news/collection/tests/feed/stream/tests.py @@ -11,9 +11,9 @@ from newsreader.news.collection.exceptions import ( StreamTimeOutException, ) from newsreader.news.collection.feed import FeedStream -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory -from .mocks import simple_mock +from .mocks import simple_mock, simple_mock_parsed class FeedStreamTestCase(TestCase): @@ -29,19 +29,19 @@ class FeedStreamTestCase(TestCase): def test_simple_stream(self): self.mocked_fetch.return_value = MagicMock(content=simple_mock) - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) data, stream = stream.read() self.mocked_fetch.assert_called_once_with(rule.url) - self.assertEquals(data["entries"], data["entries"]) - self.assertEquals(stream, stream) + self.assertEquals(data, simple_mock_parsed) + self.assertEquals(stream.rule, rule) def test_stream_raises_exception(self): self.mocked_fetch.side_effect = StreamException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamException): @@ -52,7 +52,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_denied_exception(self): self.mocked_fetch.side_effect = StreamDeniedException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamDeniedException): @@ -63,7 +63,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_not_found_exception(self): self.mocked_fetch.side_effect = StreamNotFoundException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamNotFoundException): @@ -74,7 +74,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_time_out_exception(self): self.mocked_fetch.side_effect = StreamTimeOutException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamTimeOutException): @@ -85,7 +85,7 @@ class FeedStreamTestCase(TestCase): def test_stream_raises_forbidden_exception(self): self.mocked_fetch.side_effect = StreamForbiddenException - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamForbiddenException): @@ -98,7 +98,7 @@ class FeedStreamTestCase(TestCase): self.mocked_fetch.return_value = MagicMock() mocked_parse.side_effect = TypeError - rule = CollectionRuleFactory() + rule = FeedFactory() stream = FeedStream(rule) with self.assertRaises(StreamParseException): diff --git a/src/newsreader/news/collection/tests/reddit/__init__.py b/src/newsreader/news/collection/tests/reddit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/builder/__init__.py b/src/newsreader/news/collection/tests/reddit/builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/builder/mocks.py b/src/newsreader/news/collection/tests/reddit/builder/mocks.py new file mode 100644 index 0000000..53ce372 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/builder/mocks.py @@ -0,0 +1,1378 @@ +simple_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm0qct", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.7, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 8, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 8, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594037482.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": "<!-- SC_OFF --><div class='md'><p>Welcome to <a href='/r/linux'>r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href='/r/linux'>r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href='/r/linuxadmin'>r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href='/r/linuxquestions'>r/linuxquestions</a>, <a href='/r/linux4noobs'>r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm0qct", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 9, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "subreddit_subscribers": 544037, + "created_utc": 1594008682.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Weekly Questions and Hardware Thread - July 08, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hna75r", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.6, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 2, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 2, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594210138.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "new", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hna75r", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 2, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "subreddit_subscribers": 544037, + "created_utc": 1594181338.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_gr7k5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Here's a feature Linux could borrow from BSD: in-kernel debugger with built-in hangman game", + "link_flair_richtext": [{"e": "text", "t": "Fluff"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngs71", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.9, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 158, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Fluff", + "can_mod_post": False, + "score": 158, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242629.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/wmc8tp2ium951.jpg", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "af8918be-6777-11e7-8273-0e925d908786", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#9a2bff", + "id": "hngs71", + "is_robot_indexable": True, + "report_reasons": None, + "author": "the_humeister", + "discussion_type": None, + "num_comments": 21, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hngs71/heres_a_feature_linux_could_borrow_from_bsd/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/wmc8tp2ium951.jpg", + "subreddit_subscribers": 544037, + "created_utc": 1594213829.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_k9f35", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "KeePassXC 2.6.0 released", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngsj8", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.97, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 151, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 151, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242666.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "keepassxc.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hngsj8", + "is_robot_indexable": True, + "report_reasons": None, + "author": "nixcraft", + "discussion_type": None, + "num_comments": 46, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hngsj8/keepassxc_260_released/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "subreddit_subscribers": 544037, + "created_utc": 1594213866.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hmytic", + "before": None, + }, +} + +empty_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [], + "after": "t3_hmytic", + "before": None, + }, +} + +unknown_mock = { + "kind": "Comment", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "after": "t3_hmytic", + "before": None, + }, +} + +unsanitized_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": "
", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + } + ], + "after": "t3_hmytic", + "before": None, + }, +} + +author_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": "
", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZeroTheQuantumZeroTheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + } + ], + "after": "t3_hmytic", + "before": None, + }, +} + +title_mock = { + "kind": "Listing", + "data": { + "modhash": "rjewztai5w0ab64547311ae1fb1f9cf81cd18949bfb629cb7f", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on the LibreOffice 7.0 RC "Personal Edition" labelonal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 226, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 226, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 29, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 29, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": "
", + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZeroTheQuantumZeroTheQuantumZero", + "discussion_type": None, + "num_comments": 120, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 544037, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + } + ], + "after": "t3_hmytic", + "before": None, + }, +} diff --git a/src/newsreader/news/collection/tests/reddit/builder/tests.py b/src/newsreader/news/collection/tests/reddit/builder/tests.py new file mode 100644 index 0000000..3085199 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/builder/tests.py @@ -0,0 +1,185 @@ +from datetime import datetime +from unittest.mock import MagicMock + +from django.test import TestCase + +import pytz + +from newsreader.news.collection.reddit import RedditBuilder +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.reddit.builder.mocks import ( + author_mock, + empty_mock, + simple_mock, + title_mock, + unknown_mock, + unsanitized_mock, +) +from newsreader.news.core.models import Post +from newsreader.news.core.tests.factories import PostFactory + + +class RedditBuilderTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + def test_simple_mock(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((simple_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys() + ) + + post = posts["hm0qct"] + + self.assertEquals(post.rule, subreddit) + self.assertEquals( + post.title, + "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + ) + self.assertIn( + " This megathread is also to hear opinions from anyone just starting out" + " with Linux or those that have used Linux (GNU or otherwise) for a long", + post.body, + ) + + self.assertIn( + "

For those looking for certifications please use this megathread to ask about how" + " to get certified whether it's for the business world or for your own satisfaction." + ' Be sure to check out r/linuxadmin for more discussion in the' + " SysAdmin world!

", + post.body, + ) + + self.assertEquals(post.author, "AutoModerator") + self.assertEquals( + post.url, + "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + ) + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 7, 6, 6, 11, 22)) + ) + + def test_empty_data(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((empty_mock, mock_stream)) as builder: + builder.save() + + self.assertEquals(Post.objects.count(), 0) + + def test_unknown_mock(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((unknown_mock, mock_stream)) as builder: + builder.save() + + self.assertEquals(Post.objects.count(), 0) + + def test_update_posts(self): + subreddit = SubredditFactory() + existing_publication_date = pytz.utc.localize(datetime(2020, 7, 8, 14, 0, 0)) + existing_post = PostFactory( + remote_identifier="hngsj8", + publication_date=existing_publication_date, + author="Old author", + title="Old title", + body="Old body", + url="https://bbc.com/", + rule=subreddit, + ) + + builder = RedditBuilder + mock_stream = MagicMock(rule=subreddit) + + with builder((simple_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual( + ("hm0qct", "hna75r", "hngs71", "hngsj8", "hnd7cy"), posts.keys() + ) + + existing_post.refresh_from_db() + + self.assertEquals(existing_post.remote_identifier, "hngsj8") + self.assertEquals(existing_post.author, "nixcraft") + self.assertEquals(existing_post.title, "KeePassXC 2.6.0 released") + self.assertEquals(existing_post.body, "") + self.assertEquals( + existing_post.publication_date, + pytz.utc.localize(datetime(2020, 7, 8, 15, 11, 6)), + ) + self.assertEquals( + existing_post.url, + "https://www.reddit.com/r/linux/comments/hngsj8/" "keepassxc_260_released/", + ) + + def test_html_sanitizing(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((unsanitized_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hnd7cy",), posts.keys()) + + post = posts["hnd7cy"] + + self.assertEquals(post.body, "
") + + def test_long_author_text_is_truncated(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((author_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hnd7cy",), posts.keys()) + + post = posts["hnd7cy"] + + self.assertEquals(post.author, "TheQuantumZeroTheQuantumZeroTheQuantumZ…") + + def test_long_title_text_is_truncated(self): + builder = RedditBuilder + + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + with builder((title_mock, mock_stream)) as builder: + builder.save() + + posts = {post.remote_identifier: post for post in Post.objects.all()} + + self.assertCountEqual(("hnd7cy",), posts.keys()) + + post = posts["hnd7cy"] + + self.assertEquals( + post.title, + 'Board statement on the LibreOffice 7.0 RC "Personal EditionBoard statement on the LibreOffice 7.0 RC "Personal Edition" label" labelBoard statement on the LibreOffice 7.0 RC "PersBoard statement on t…', + ) diff --git a/src/newsreader/news/collection/tests/reddit/client/__init__.py b/src/newsreader/news/collection/tests/reddit/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/client/mocks.py b/src/newsreader/news/collection/tests/reddit/client/mocks.py new file mode 100644 index 0000000..6a11409 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/client/mocks.py @@ -0,0 +1,160 @@ +# Note that some response data is truncated + +simple_mock = { + "data": { + "after": "t3_hjywyf", + "before": None, + "children": [ + { + "data": { + "approved_at_utc": None, + "approved_by": None, + "archived": False, + "author": "AutoModerator", + "banned_at_utc": None, + "banned_by": None, + "category": None, + "content_categories": None, + "created": 1593605471.0, + "created_utc": 1593576671.0, + "discussion_type": None, + "distinguished": "moderator", + "domain": "self.linux", + "edited": False, + "hidden": False, + "id": "hj34ck", + "locked": False, + "name": "t3_hj34ck", + "permalink": "/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/", + "pinned": False, + "selftext": "Welcome to r/linux! If you're " + "new to Linux or trying to get " + "started this thread is for you. " + "Get help here or as always, " + "check out r/linuxquestions or " + "r/linux4noobs\n" + "\n" + "This megathread is for all your " + "question needs. As we don't " + "allow questions on r/linux " + "outside of this megathread, " + "please consider using " + "r/linuxquestions or " + "r/linux4noobs for the best " + "solution to your problem.\n" + "\n" + "Ask your hardware requests here " + "too or try r/linuxhardware!", + "selftext_html": "<!-- SC_OFF " + "--><div " + 'class="md"><p>Welcome ' + "to <a " + 'href="/r/linux">r/linux</a>! ' + "If you&#39;re new to " + "Linux or trying to get " + "started this thread is for " + "you. Get help here or as " + "always, check out <a " + 'href="/r/linuxquestions">r/linuxquestions</a> ' + "or <a " + 'href="/r/linux4noobs">r/linux4noobs</a></p>\n' + "\n" + "<p>This megathread is " + "for all your question " + "needs. As we don&#39;t " + "allow questions on <a " + 'href="/r/linux">r/linux</a> ' + "outside of this megathread, " + "please consider using <a " + 'href="/r/linuxquestions">r/linuxquestions</a> ' + "or <a " + 'href="/r/linux4noobs">r/linux4noobs</a> ' + "for the best solution to " + "your problem.</p>\n" + "\n" + "<p>Ask your hardware " + "requests here too or try " + "<a " + 'href="/r/linuxhardware">r/linuxhardware</a>!</p>\n' + "</div><!-- SC_ON " + "-->", + "spoiler": False, + "stickied": True, + "subreddit": "linux", + "subreddit_id": "t5_2qh1a", + "subreddit_name_prefixed": "r/linux", + "title": "Weekly Questions and Hardware " "Thread - July 01, 2020", + "url": "https://www.reddit.com/r/linux/comments/hj34ck/weekly_questions_and_hardware_thread_july_01_2020/", + "visited": False, + }, + "kind": "t3", + }, + { + "data": { + "archived": False, + "author": "AutoModerator", + "banned_at_utc": None, + "banned_by": None, + "category": None, + "created": 1593824903.0, + "created_utc": 1593796103.0, + "discussion_type": None, + "domain": "self.linux", + "edited": False, + "hidden": False, + "id": "hkmu0t", + "name": "t3_hkmu0t", + "permalink": "/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/", + "pinned": False, + "saved": False, + "selftext": "Welcome to the weekend! This " + "stickied thread is for you to " + "post pictures of your ubuntu " + "2006 install disk, slackware " + "floppies, on-topic memes or " + "more.\n" + "\n" + "When it's not the weekend, be " + "sure to check out " + "r/WildLinuxAppears or " + "r/linuxmemes!", + "selftext_html": "<!-- SC_OFF " + "--><div " + 'class="md"><p>Welcome ' + "to the weekend! This " + "stickied thread is for you " + "to post pictures of your " + "ubuntu 2006 install disk, " + "slackware floppies, " + "on-topic memes or " + "more.</p>\n" + "\n" + "<p>When it&#39;s " + "not the weekend, be sure to " + "check out <a " + 'href="/r/WildLinuxAppears">r/WildLinuxAppears</a> ' + "or <a " + 'href="/r/linuxmemes">r/linuxmemes</a>!</p>\n' + "</div><!-- SC_ON " + "-->", + "spoiler": False, + "stickied": True, + "subreddit": "linux", + "subreddit_id": "t5_2qh1a", + "subreddit_name_prefixed": "r/linux", + "subreddit_subscribers": 542073, + "subreddit_type": "public", + "thumbnail": "", + "title": "Weekend Fluff / Linux in the Wild " + "Thread - July 03, 2020", + "url": "https://www.reddit.com/r/linux/comments/hkmu0t/weekend_fluff_linux_in_the_wild_thread_july_03/", + "visited": False, + }, + "kind": "t3", + }, + ], + "dist": 27, + "modhash": None, + }, + "kind": "Listing", +} diff --git a/src/newsreader/news/collection/tests/reddit/client/tests.py b/src/newsreader/news/collection/tests/reddit/client/tests.py new file mode 100644 index 0000000..f2ee84d --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/client/tests.py @@ -0,0 +1,164 @@ +from unittest.mock import MagicMock, patch +from uuid import uuid4 + +from django.test import TestCase +from django.utils.lorem_ipsum import words + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, + StreamTooManyException, +) +from newsreader.news.collection.reddit import RedditClient +from newsreader.news.collection.tests.factories import SubredditFactory + +from .mocks import simple_mock + + +class RedditClientTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.patched_read = patch("newsreader.news.collection.reddit.RedditStream.read") + self.mocked_read = self.patched_read.start() + + def tearDown(self): + patch.stopall() + + def test_client_retrieves_single_rules(self): + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + self.mocked_read.return_value = (simple_mock, mock_stream) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, simple_mock) + self.assertEquals(stream, mock_stream) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamException(message="Stream exception") + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + 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): + subreddit = SubredditFactory.create() + + self.mocked_read.side_effect = StreamNotFoundException( + message="Stream not found" + ) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Stream not found") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + @patch("newsreader.news.collection.reddit.RedditTokenTask") + def test_client_catches_stream_denied_exception(self, mocked_task): + user = UserFactory( + reddit_access_token=str(uuid4()), reddit_refresh_token=str(uuid4()) + ) + subreddit = SubredditFactory(user=user) + + self.mocked_read.side_effect = StreamDeniedException(message="Token expired") + + with RedditClient([(subreddit,)]) as client: + results = [(data, stream) for data, stream in client] + + self.mocked_read.assert_called_once_with() + mocked_task.delay.assert_called_once_with(user.pk) + + self.assertEquals(len(results), 0) + + user.refresh_from_db() + subreddit.refresh_from_db() + + self.assertEquals(user.reddit_access_token, None) + self.assertEquals(subreddit.succeeded, False) + self.assertEquals(subreddit.error, "Token expired") + + def test_client_catches_stream_timed_out_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamTimeOutException( + message="Stream timed out" + ) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Stream timed out") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_too_many_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamTooManyException + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Too many requests") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_stream_parse_exception(self): + subreddit = SubredditFactory() + + self.mocked_read.side_effect = StreamParseException( + message="Stream could not be parsed" + ) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + with self.subTest(data=data, stream=stream): + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(stream.rule.error, "Stream could not be parsed") + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() + + def test_client_catches_long_exception_text(self): + subreddit = SubredditFactory() + mock_stream = MagicMock(rule=subreddit) + + self.mocked_read.side_effect = StreamParseException(message=words(1000)) + + with RedditClient([[subreddit]]) as client: + for data, stream in client: + self.assertEquals(data, None) + self.assertEquals(stream, None) + self.assertEquals(len(stream.rule.error), 1024) + self.assertEquals(stream.rule.succeeded, False) + + self.mocked_read.assert_called_once_with() diff --git a/src/newsreader/news/collection/tests/reddit/collector/__init__.py b/src/newsreader/news/collection/tests/reddit/collector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/collector/mocks.py b/src/newsreader/news/collection/tests/reddit/collector/mocks.py new file mode 100644 index 0000000..37d40d8 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/collector/mocks.py @@ -0,0 +1,1662 @@ +simple_mock_1 = { + "kind": "Listing", + "data": { + "modhash": "khwcr8tmp613f1b92d55150adb744983e7f6c37e87e30f6432", + "dist": 26, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!\r\n\r\n---\r\n\r\nUseful Links and Resources:\r\n\r\n[Star Citizen Wiki](https://starcitizen.tools) - *The biggest and best wiki resource dedicated to Star Citizen*\r\n\r\n[Star Citizen FAQ](https://starcitizen.tools/Frequently_Asked_Questions) - *Chances the answer you need is here.* \r\n\r\n[Discord Help Channel](https://discord.gg/0STCP5tSe7x9NBSq) - *Often times community members will be here to help you with issues.*\r\n\r\n[Referral Code Randomizer](http://gorefer.me/starcitizen) - *Use this when creating a new account to get 5000 extra UEC.*\r\n\r\n[Download Star Citizen](https://robertsspaceindustries.com/download) - *Get the latest version of Star Citizen here*\r\n\r\n[Current Game Features](https://robertsspaceindustries.com/feature-list) - *Click here to see what you can currently do in Star Citizen.*\r\n\r\n[Development Roadmap](https://robertsspaceindustries.com/roadmap/board/1-Star-Citizen) - *The current development status of up and coming Star Citizen features.*\r\n\r\n[Pledge FAQ](https://support.robertsspaceindustries.com/hc/en-us/articles/115013194987-Pledges-FAQs) - *Official FAQ regarding spending money on the game.*", + "author_fullname": "t2_otk50", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Star Citizen: Question and Answer Thread", + "link_flair_richtext": [{"e": "text", "t": "QUESTION"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "QUESTION", + "downs": 0, + "thumbnail_height": None, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm6byg", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.9, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 21, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": None, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "QUESTION", + "can_mod_post": False, + "score": 21, + "approved_by": None, + "author_premium": False, + "thumbnail": "self", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "self", + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594065605, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.starcitizen", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to the Star Citizen question and answer thread. Feel free to ask any questions you have related to SC here!</p>\n\n<hr/>\n\n<p>Useful Links and Resources:</p>\n\n<p><a href="https://starcitizen.tools">Star Citizen Wiki</a> - <em>The biggest and best wiki resource dedicated to Star Citizen</em></p>\n\n<p><a href="https://starcitizen.tools/Frequently_Asked_Questions">Star Citizen FAQ</a> - <em>Chances the answer you need is here.</em> </p>\n\n<p><a href="https://discord.gg/0STCP5tSe7x9NBSq">Discord Help Channel</a> - <em>Often times community members will be here to help you with issues.</em></p>\n\n<p><a href="http://gorefer.me/starcitizen">Referral Code Randomizer</a> - <em>Use this when creating a new account to get 5000 extra UEC.</em></p>\n\n<p><a href="https://robertsspaceindustries.com/download">Download Star Citizen</a> - <em>Get the latest version of Star Citizen here</em></p>\n\n<p><a href="https://robertsspaceindustries.com/feature-list">Current Game Features</a> - <em>Click here to see what you can currently do in Star Citizen.</em></p>\n\n<p><a href="https://robertsspaceindustries.com/roadmap/board/1-Star-Citizen">Development Roadmap</a> - <em>The current development status of up and coming Star Citizen features.</em></p>\n\n<p><a href="https://support.robertsspaceindustries.com/hc/en-us/articles/115013194987-Pledges-FAQs">Pledge FAQ</a> - <em>Official FAQ regarding spending money on the game.</em></p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "new", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?auto=webp&s=738b5270a81373916191470a1da34cdcc54d8511", + "width": 332, + "height": 360, + }, + "resolutions": [ + { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=108&crop=smart&auto=webp&s=e2ee2a9dae15472663b52c8cb4e002fdbbb6378c", + "width": 108, + "height": 117, + }, + { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=216&crop=smart&auto=webp&s=3690c60a9b533d376f159f306c6667b47ff42102", + "width": 216, + "height": 234, + }, + { + "url": "https://external-preview.redd.it/5qLIX6ObbErxkDiTz24uP6TbZGNVNyy2SeXusPhzaKA.jpg?width=320&crop=smart&auto=webp&s=4dcb434a5071329ecbb9f3543e4d06442ab141df", + "width": 320, + "height": 346, + }, + ], + "variants": {}, + "id": "KTE3H6RnWCasOJCFtdmgmw51FMzxSqXz_SRD6W5Rdsc", + } + ], + "enabled": False, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm6byg", + "is_robot_indexable": True, + "report_reasons": None, + "author": "UEE_Central_Computer", + "discussion_type": None, + "num_comments": 380, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/starcitizen/comments/hm6byg/star_citizen_question_and_answer_thread/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/starcitizen/comments/hm6byg/star_citizen_question_and_answer_thread/", + "subreddit_subscribers": 213071, + "created_utc": 1594036805, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_6wgp9w28", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "5 random people in a train felt like such a rare and special thing 😁", + "link_flair_richtext": [{"e": "text", "t": "FLUFF"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "fluff", + "downs": 0, + "thumbnail_height": 78, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpkhgj", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.98, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 892, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": "a87724f8-c2b5-11e4-b7e0-22000b2103f6", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "FLUFF", + "can_mod_post": False, + "score": 892, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://b.thumbs.redditmedia.com/YlF6BTm-DfnrZBeukYiOyrP-Fkj2xUQtk_V8ZeUD93w.jpg", + "edited": False, + "author_flair_css_class": "aurora", + "author_flair_richtext": [ + {"e": "text", "t": "🌌2013Backer🎮vGameDev🌌"} + ], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594540209, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/0jkge020fba51.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/0jkge020fba51.png?auto=webp&s=c3a2b8cb860f839638a364d49abca04fd4f42094", + "width": 2560, + "height": 1440, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=108&crop=smart&auto=webp&s=778a7f7d9b2e0d713161e84b32c467ebde6cbc17", + "width": 108, + "height": 60, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=216&crop=smart&auto=webp&s=53afc50cc2dd6c72470e76a4c3ff8ef597f66e0d", + "width": 216, + "height": 121, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=320&crop=smart&auto=webp&s=089f9ff42e429b5062c143695e695cbb4ea5b679", + "width": 320, + "height": 180, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=640&crop=smart&auto=webp&s=045327ac6fd113630c0faef426d86efaf04f55e2", + "width": 640, + "height": 360, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=960&crop=smart&auto=webp&s=efbdc9ddcda1207fafa20bb45e82fbe24ed37df8", + "width": 960, + "height": 540, + }, + { + "url": "https://preview.redd.it/0jkge020fba51.png?width=1080&crop=smart&auto=webp&s=1b94c9951c60a788357dfa0fe21dd983efdcf1e7", + "width": 1080, + "height": 607, + }, + ], + "variants": {}, + "id": "r-JjrJn0RtZLaxMk_d-TCfW80pWgJ-5kjMaje54J5_I", + } + ], + "enabled": True, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "db099dc4-3538-11e5-97ec-0e7f0fa558f9", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "🌌2013Backer🎮vGameDev🌌", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#007373", + "id": "hpkhgj", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Y_DK_Y", + "discussion_type": None, + "num_comments": 39, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/starcitizen/comments/hpkhgj/5_random_people_in_a_train_felt_like_such_a_rare/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/0jkge020fba51.png", + "subreddit_subscribers": 213071, + "created_utc": 1594511409, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_4brylpu5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Drake Interplanetary Smartkey thing that I made!", + "link_flair_richtext": [{"e": "text", "t": "ARTWORK"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "artwork", + "downs": 0, + "thumbnail_height": 78, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hph00n", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.97, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 547, + "total_awards_received": 1, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": True, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "ARTWORK", + "can_mod_post": False, + "score": 547, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://b.thumbs.redditmedia.com/gr7RYEjNN5FNc42LxuizFW_ZxWtS3xbZj1QfhIa-2Hw.jpg", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594527804, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/b6h74eljeaa51.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/b6h74eljeaa51.png?auto=webp&s=fd286c2dcd98378c34fde6e245cf13c357716dca", + "width": 1920, + "height": 1080, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=108&crop=smart&auto=webp&s=3150c2a2643d178eba735cb0bc222b8b29f46c8c", + "width": 108, + "height": 60, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=216&crop=smart&auto=webp&s=9120ce40ce7439ca4d3431da7782a8c6acd2eebf", + "width": 216, + "height": 121, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=320&crop=smart&auto=webp&s=83cd5c93fe7a19e5643df38eec3aefee54912faf", + "width": 320, + "height": 180, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=640&crop=smart&auto=webp&s=b3e280a4a7fbaf794692c01f4ff63af0b8559700", + "width": 640, + "height": 360, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=960&crop=smart&auto=webp&s=8ebac203688ba0e42c7975f3d7688dab25fc065b", + "width": 960, + "height": 540, + }, + { + "url": "https://preview.redd.it/b6h74eljeaa51.png?width=1080&crop=smart&auto=webp&s=8350e0b4e004820ef9f30501397d49a2121186ec", + "width": 1080, + "height": 607, + }, + ], + "variants": {}, + "id": "B2HxXfFibxKUtHO9eBwT-Bt_VrE870XhC0R5OFA95rI", + } + ], + "enabled": True, + }, + "all_awardings": [ + { + "giver_coin_reward": 0, + "subreddit_id": None, + "is_new": False, + "days_of_drip_extension": 0, + "coin_price": 50, + "id": "award_02d9ab2c-162e-4c01-8438-317a016ed3d9", + "penny_donate": 0, + "award_sub_type": "GLOBAL", + "coin_reward": 0, + "icon_url": "https://i.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png", + "days_of_premium": 0, + "resized_icons": [ + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=16&height=16&auto=webp&s=92e96be1dbd278dc987fbd9acc1bd5078566f254", + "width": 16, + "height": 16, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=32&height=32&auto=webp&s=83e14655f2b162b295f7d2c7058b9ad94cf8b73c", + "width": 32, + "height": 32, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=48&height=48&auto=webp&s=83038a4d6181d3c8f5107dbca4ddb735ca6c2231", + "width": 48, + "height": 48, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=64&height=64&auto=webp&s=3c4e39a7664d799ff50f32e9a3f96c3109d2e266", + "width": 64, + "height": 64, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=128&height=128&auto=webp&s=390bf9706b8e1a6215716ebcf6363373f125c339", + "width": 128, + "height": 128, + }, + ], + "icon_width": 2048, + "static_icon_width": 2048, + "start_date": None, + "is_enabled": True, + "description": "I'm in this with you.", + "end_date": None, + "subreddit_coin_reward": 0, + "count": 1, + "static_icon_height": 2048, + "name": "Take My Energy", + "resized_static_icons": [ + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=16&height=16&auto=webp&s=92e96be1dbd278dc987fbd9acc1bd5078566f254", + "width": 16, + "height": 16, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=32&height=32&auto=webp&s=83e14655f2b162b295f7d2c7058b9ad94cf8b73c", + "width": 32, + "height": 32, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=48&height=48&auto=webp&s=83038a4d6181d3c8f5107dbca4ddb735ca6c2231", + "width": 48, + "height": 48, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=64&height=64&auto=webp&s=3c4e39a7664d799ff50f32e9a3f96c3109d2e266", + "width": 64, + "height": 64, + }, + { + "url": "https://preview.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png?width=128&height=128&auto=webp&s=390bf9706b8e1a6215716ebcf6363373f125c339", + "width": 128, + "height": 128, + }, + ], + "icon_format": "PNG", + "icon_height": 2048, + "penny_price": 0, + "award_type": "global", + "static_icon_url": "https://i.redd.it/award_images/t5_22cerq/898sygoknoo41_TakeMyEnergy.png", + } + ], + "awarders": [], + "media_only": False, + "link_flair_template_id": "e3bb68b2-3538-11e5-bf5a-0e09b4299f63", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#ff66ac", + "id": "hph00n", + "is_robot_indexable": True, + "report_reasons": None, + "author": "HannahB888", + "discussion_type": None, + "num_comments": 38, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/starcitizen/comments/hph00n/drake_interplanetary_smartkey_thing_that_i_made/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/b6h74eljeaa51.png", + "subreddit_subscribers": 213071, + "created_utc": 1594499004, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_exlc6", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "A Historical Moment for CIG", + "link_flair_richtext": [{"e": "text", "t": "FLUFF"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "fluff", + "downs": 0, + "thumbnail_height": 37, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hp9mlw", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.98, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 1444, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "FLUFF", + "can_mod_post": False, + "score": 1444, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://b.thumbs.redditmedia.com/YYdiE2x8fsn0ckVJiGCnBzUIOa1DA03ALh3TJuVlZks.jpg", + "edited": False, + "author_flair_css_class": "carrack", + "author_flair_richtext": [{"e": "text", "t": "AHV Artemis"}], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594501406, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/fdh2ujp388a51.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/fdh2ujp388a51.png?auto=webp&s=605044c2757c1b5ca9060d3ec448090396a2f0dd", + "width": 424, + "height": 114, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/fdh2ujp388a51.png?width=108&crop=smart&auto=webp&s=9789c6b76d45e46645fe2454555bfbd042a39815", + "width": 108, + "height": 29, + }, + { + "url": "https://preview.redd.it/fdh2ujp388a51.png?width=216&crop=smart&auto=webp&s=3f419183835c883f10b1caab3a7ecbec4ebbf3ec", + "width": 216, + "height": 58, + }, + { + "url": "https://preview.redd.it/fdh2ujp388a51.png?width=320&crop=smart&auto=webp&s=695ff914462b5b9bc253ce26f4a51f5f22641148", + "width": 320, + "height": 86, + }, + ], + "variants": {}, + "id": "XWdU5CBWG0-5mOzBRF65OnvZzQm2Btd2ldGMeJ8u_gI", + } + ], + "enabled": True, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "db099dc4-3538-11e5-97ec-0e7f0fa558f9", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "AHV Artemis", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#007373", + "id": "hp9mlw", + "is_robot_indexable": True, + "report_reasons": None, + "author": "sam00197", + "discussion_type": None, + "num_comments": 194, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/starcitizen/comments/hp9mlw/a_historical_moment_for_cig/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/fdh2ujp388a51.png", + "subreddit_subscribers": 213071, + "created_utc": 1594472606, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "starcitizen", + "selftext": "", + "author_fullname": "t2_4dgjlpn7", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "This view. What's your favorite moon?", + "link_flair_richtext": [{"e": "text", "t": "DISCUSSION"}], + "subreddit_name_prefixed": "r/starcitizen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "discussion", + "downs": 0, + "thumbnail_height": 78, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpjn8x", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.96, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 182, + "total_awards_received": 0, + "media_embed": {}, + "thumbnail_width": 140, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "DISCUSSION", + "can_mod_post": False, + "score": 182, + "approved_by": None, + "author_premium": False, + "thumbnail": "https://a.thumbs.redditmedia.com/tKHL_2fn4Zo9FhrtP3UiJlQA7xkMU7-iN0ntJbhfa80.jpg", + "edited": False, + "author_flair_css_class": "", + "author_flair_richtext": [{"e": "text", "t": "new user/low karma"}], + "gildings": {}, + "post_hint": "image", + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594537150, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ovly7f9g6ba51.jpg", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "preview": { + "images": [ + { + "source": { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?auto=webp&s=d7051e4c713e39c642c583e5e8ada57c9660fa26", + "width": 2560, + "height": 1440, + }, + "resolutions": [ + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=108&crop=smart&auto=webp&s=35f6ebe4531c12bc24532f01741bcf8100d954b2", + "width": 108, + "height": 60, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=216&crop=smart&auto=webp&s=a939922e34cf4ff6a82eeb22e71acb816ccc6d7b", + "width": 216, + "height": 121, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=320&crop=smart&auto=webp&s=9796767ed73e04a774d2f1ba8cf3662bbd4195eb", + "width": 320, + "height": 180, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=640&crop=smart&auto=webp&s=37fe4c262b752cb8dac903daf606be8f0ac3b44f", + "width": 640, + "height": 360, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=960&crop=smart&auto=webp&s=305245fd1d352634c86459131b11238fe09f5d2b", + "width": 960, + "height": 540, + }, + { + "url": "https://preview.redd.it/ovly7f9g6ba51.jpg?width=1080&crop=smart&auto=webp&s=e8438e4b666cf616646ffad09c153d120df1f1d9", + "width": 1080, + "height": 607, + }, + ], + "variants": {}, + "id": "SjRqA5h_B55WLnwAlocF6wcxIHZLgGBMpmb5nV1EQ4E", + } + ], + "enabled": True, + }, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "ca858044-1916-11e2-a9b9-12313d168e98", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "new user/low karma", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2v94d", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#014980", + "id": "hpjn8x", + "is_robot_indexable": True, + "report_reasons": None, + "author": "clericanubis", + "discussion_type": None, + "num_comments": 27, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/starcitizen/comments/hpjn8x/this_view_whats_your_favorite_moon/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ovly7f9g6ba51.jpg", + "subreddit_subscribers": 213071, + "created_utc": 1594508350, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hplinp", + "before": None, + }, +} + +simple_mock_2 = { + "kind": "Listing", + "data": { + "modhash": "y4he8gfzh9f892e2bf3094bc06daba2e02288e617fecf555b5", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "Top Level comments must be **Job Opportunities.**\n\nPlease include **Location** or any other **Requirements** in your comment. If you require people to work on site in San Francisco, *you must note that in your post.* If you require an Engineering degree, *you must note that in your post*.\n\nPlease include as much information as possible.\n\nIf you are looking for jobs, send a PM to the poster.", + "author_fullname": "t2_628u", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "/r/Python Job Board for May, June, July", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_gdfaip", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.98, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 108, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 108, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": "", + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1588640187, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.Python", + "allow_live_comments": True, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Top Level comments must be <strong>Job Opportunities.</strong></p>\n\n<p>Please include <strong>Location</strong> or any other <strong>Requirements</strong> in your comment. If you require people to work on site in San Francisco, <em>you must note that in your post.</em> If you require an Engineering degree, <em>you must note that in your post</em>.</p>\n\n<p>Please include as much information as possible.</p>\n\n<p>If you are looking for jobs, send a PM to the poster.</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "reticulated", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "gdfaip", + "is_robot_indexable": True, + "report_reasons": None, + "author": "aphoenix", + "discussion_type": None, + "num_comments": 38, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/Python/comments/gdfaip/rpython_job_board_for_may_june_july/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/Python/comments/gdfaip/rpython_job_board_for_may_june_july/", + "subreddit_subscribers": 616297, + "created_utc": 1588611387, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "# EDIT: AMA complete. Huge thanks to the PyCharm Team for holding this!\n\nAs mentioned in the comments you can use code `reddit20202` at [https://www.jetbrains.com/store/redeem/](https://www.jetbrains.com/store/redeem/) to try out PyCharm Professional as a new JetBrains customer!\n\nWe will be joined by members of the PyCharm Developer team from JetBrains to answer all sorts of questions on the PyCharm IDE and the Python language!\n\n[PyCharm](https://www.jetbrains.com/pycharm/) is the professional IDE for Python Developers with over 33% of respondents from the [2019 Python Developers Survey](https://www.jetbrains.com/lp/python-developers-survey-2019/) choosing it as their main editor.\n\nPyCharm features smart autocompletion, on-the-fly error checking and quick fixes as well as PEP8 compliance detection and automatic refactoring.\n\nIf you haven't checked out PyCharm then you definitely should, the Community Edition of PyCharm includes many key features such as the debugger, test runners, intelligent code completion and more!\n\nIf you are looking for a professional IDE for Python then the PyCharm Professional edition adds features such as advanced web development tools and database/SQL support, if you are a student or maintain an open source project make sure to take a look at the generous discounts JetBrains offer for their products!\n\nThe AMA will begin at 16:00 UTC on the 9th of July. Feel free to drop questions below for the PyCharm team to answer!\n\nWe will be joined by:\n\n* Nafiul Islam, u/nafiulislamjb (Developer Advocate for PyCharm)\n* Andrey Vlasovskikh, u/vlasovskikh (PyCharm Team Lead)", + "user_reports": [], + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "AMA with PyCharm team from JetBrains on 9th July @ 16:00 UTC", + "event_start": 1594310400, + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "editors", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmd2ez", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.94, + "author_flair_background_color": "", + "subreddit_type": "public", + "ups": 60, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "author_fullname": "t2_145f96", + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Editors / IDEs", + "can_mod_post": False, + "score": 60, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": 1594321779, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594088635, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.Python", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><h1>EDIT: AMA complete. Huge thanks to the PyCharm Team for holding this!</h1>\n\n<p>As mentioned in the comments you can use code <code>reddit20202</code> at <a href="https://www.jetbrains.com/store/redeem/">https://www.jetbrains.com/store/redeem/</a> to try out PyCharm Professional as a new JetBrains customer!</p>\n\n<p>We will be joined by members of the PyCharm Developer team from JetBrains to answer all sorts of questions on the PyCharm IDE and the Python language!</p>\n\n<p><a href="https://www.jetbrains.com/pycharm/">PyCharm</a> is the professional IDE for Python Developers with over 33% of respondents from the <a href="https://www.jetbrains.com/lp/python-developers-survey-2019/">2019 Python Developers Survey</a> choosing it as their main editor.</p>\n\n<p>PyCharm features smart autocompletion, on-the-fly error checking and quick fixes as well as PEP8 compliance detection and automatic refactoring.</p>\n\n<p>If you haven&#39;t checked out PyCharm then you definitely should, the Community Edition of PyCharm includes many key features such as the debugger, test runners, intelligent code completion and more!</p>\n\n<p>If you are looking for a professional IDE for Python then the PyCharm Professional edition adds features such as advanced web development tools and database/SQL support, if you are a student or maintain an open source project make sure to take a look at the generous discounts JetBrains offer for their products!</p>\n\n<p>The AMA will begin at 16:00 UTC on the 9th of July. Feel free to drop questions below for the PyCharm team to answer!</p>\n\n<p>We will be joined by:</p>\n\n<ul>\n<li>Nafiul Islam, <a href="/u/nafiulislamjb">u/nafiulislamjb</a> (Developer Advocate for PyCharm)</li>\n<li>Andrey Vlasovskikh, <a href="/u/vlasovskikh">u/vlasovskikh</a> (PyCharm Team Lead)</li>\n</ul>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "confidence", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "49f2747c-4114-11ea-b9fe-0e741fe75651", + "link_flair_richtext": [], + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "Owner of Python Discord", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh0y", + "event_end": 1594324800, + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "event_is_live": False, + "id": "hmd2ez", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Im__Joseph", + "discussion_type": None, + "num_comments": 65, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/Python/comments/hmd2ez/ama_with_pycharm_team_from_jetbrains_on_9th_july/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/Python/comments/hmd2ez/ama_with_pycharm_team_from_jetbrains_on_9th_july/", + "subreddit_subscribers": 616297, + "created_utc": 1594059835, + "num_crossposts": 2, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "", + "author_fullname": "t2_woll6", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "I am a medical student, and I recently programmed an open-source eye-tracker for brain research", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "made-this", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpr28u", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.99, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 439, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "4cc838b8-3159-11e1-83e4-12313d18ad57", + "is_original_content": False, + "user_reports": [], + "secure_media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/tqzx750wzda51/DASH_360.mp4?source=fallback", + "height": 384, + "width": 512, + "scrubber_media_url": "https://v.redd.it/tqzx750wzda51/DASH_96.mp4", + "dash_url": "https://v.redd.it/tqzx750wzda51/DASHPlaylist.mpd?a=1597142191%2CY2JkNmU5Y2FmZGM1NzA5MjhkYTk5NjdmMWRmNWI4M2I2N2Q2MjA5NmIzZWRmODJiMjk0MzY4OTZlYTBiZmZlZg%3D%3D&v=1&f=sd", + "duration": 31, + "hls_url": "https://v.redd.it/tqzx750wzda51/HLSPlaylist.m3u8?a=1597142191%2CZDVhNWNjMGQ0OTBjOTU0Zjk5MDgwZmE2YzA1MGY5YzNlZThmZTAxZTgxODIxMGFjZDdlYzczOWFlYTcyMmMzNg%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "I Made This", + "can_mod_post": False, + "score": 439, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594571350, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "v.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://v.redd.it/tqzx750wzda51", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "d7dfae22-4113-11ea-b9fe-0e741fe75651", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "Neuroscientist", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hpr28u", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Sebaron", + "discussion_type": None, + "num_comments": 33, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/Python/comments/hpr28u/i_am_a_medical_student_and_i_recently_programmed/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://v.redd.it/tqzx750wzda51", + "subreddit_subscribers": 616297, + "created_utc": 1594542550, + "num_crossposts": 0, + "media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/tqzx750wzda51/DASH_360.mp4?source=fallback", + "height": 384, + "width": 512, + "scrubber_media_url": "https://v.redd.it/tqzx750wzda51/DASH_96.mp4", + "dash_url": "https://v.redd.it/tqzx750wzda51/DASHPlaylist.mpd?a=1597142191%2CY2JkNmU5Y2FmZGM1NzA5MjhkYTk5NjdmMWRmNWI4M2I2N2Q2MjA5NmIzZWRmODJiMjk0MzY4OTZlYTBiZmZlZg%3D%3D&v=1&f=sd", + "duration": 31, + "hls_url": "https://v.redd.it/tqzx750wzda51/HLSPlaylist.m3u8?a=1597142191%2CZDVhNWNjMGQ0OTBjOTU0Zjk5MDgwZmE2YzA1MGY5YzNlZThmZTAxZTgxODIxMGFjZDdlYzczOWFlYTcyMmMzNg%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_video": True, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "", + "author_fullname": "t2_6zgzj94n", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "I made a filename simplifier which removes unnecessary tags, metadata, dashes, dots, underscores, and non-English characters from filenames (and folders) to give your library a neat look.", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "made-this", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hpps6f", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 258, + "total_awards_received": 1, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/jq229anzada51/DASH_1080.mp4?source=fallback", + "height": 1080, + "width": 1920, + "scrubber_media_url": "https://v.redd.it/jq229anzada51/DASH_96.mp4", + "dash_url": "https://v.redd.it/jq229anzada51/DASHPlaylist.mpd?a=1597142191%2CZDU4Y2FmYzI2NjMzZTMxNzJkOThiMzJmYzBlOTMyMmEwNTg3MTFhMmU0OWZjZDljZGQ4MjAwMTgxMGVhYzU1OQ%3D%3D&v=1&f=sd", + "duration": 27, + "hls_url": "https://v.redd.it/jq229anzada51/HLSPlaylist.m3u8?a=1597142191%2CYmY1Y2Q5ZjQ0ZWVmODAxODQ3MGU3YzA1YzIxOTEzODFlNWQyMjE4MzAyYzNiMDM5NTI0N2M5OTRmY2YwN2NlOA%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "I Made This", + "can_mod_post": False, + "score": 258, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594563987, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "v.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://v.redd.it/jq229anzada51", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [ + { + "giver_coin_reward": 0, + "subreddit_id": None, + "is_new": False, + "days_of_drip_extension": 0, + "coin_price": 75, + "id": "award_9663243a-e77f-44cf-abc6-850ead2cd18d", + "penny_donate": 0, + "award_sub_type": "PREMIUM", + "coin_reward": 0, + "icon_url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_512.png", + "days_of_premium": 0, + "resized_icons": [ + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_16.png", + "width": 16, + "height": 16, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_32.png", + "width": 32, + "height": 32, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_48.png", + "width": 48, + "height": 48, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_64.png", + "width": 64, + "height": 64, + }, + { + "url": "https://www.redditstatic.com/gold/awards/icon/SnooClappingPremium_128.png", + "width": 128, + "height": 128, + }, + ], + "icon_width": 512, + "static_icon_width": 512, + "start_date": None, + "is_enabled": True, + "description": "For an especially amazing showing.", + "end_date": None, + "subreddit_coin_reward": 0, + "count": 1, + "static_icon_height": 512, + "name": "Bravo Grande!", + "resized_static_icons": [ + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=16&height=16&auto=webp&s=3459bdf1d1777821a831c5bf9834f4365263fcff", + "width": 16, + "height": 16, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=32&height=32&auto=webp&s=9181d68065ccfccf2b1074e499cd7c1103aa2ce8", + "width": 32, + "height": 32, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=48&height=48&auto=webp&s=339b368d395219120abc50d54fb3e2cdcad8ca4f", + "width": 48, + "height": 48, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=64&height=64&auto=webp&s=de4ebbe92f9019de05aaa77f88810d44adbe1e50", + "width": 64, + "height": 64, + }, + { + "url": "https://preview.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png?width=128&height=128&auto=webp&s=ba6c1add5204ea43e5af010bd9622392a42140e3", + "width": 128, + "height": 128, + }, + ], + "icon_format": "APNG", + "icon_height": 512, + "penny_price": 0, + "award_type": "global", + "static_icon_url": "https://i.redd.it/award_images/t5_q0gj4/59e02tmkl4451_BravoGrande-Static.png", + } + ], + "awarders": [], + "media_only": False, + "link_flair_template_id": "d7dfae22-4113-11ea-b9fe-0e741fe75651", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hpps6f", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Hobo-TheGodOfPoverty", + "discussion_type": None, + "num_comments": 25, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/Python/comments/hpps6f/i_made_a_filename_simplifier_which_removes/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://v.redd.it/jq229anzada51", + "subreddit_subscribers": 616297, + "created_utc": 1594535187, + "num_crossposts": 0, + "media": { + "reddit_video": { + "fallback_url": "https://v.redd.it/jq229anzada51/DASH_1080.mp4?source=fallback", + "height": 1080, + "width": 1920, + "scrubber_media_url": "https://v.redd.it/jq229anzada51/DASH_96.mp4", + "dash_url": "https://v.redd.it/jq229anzada51/DASHPlaylist.mpd?a=1597142191%2CZDU4Y2FmYzI2NjMzZTMxNzJkOThiMzJmYzBlOTMyMmEwNTg3MTFhMmU0OWZjZDljZGQ4MjAwMTgxMGVhYzU1OQ%3D%3D&v=1&f=sd", + "duration": 27, + "hls_url": "https://v.redd.it/jq229anzada51/HLSPlaylist.m3u8?a=1597142191%2CYmY1Y2Q5ZjQ0ZWVmODAxODQ3MGU3YzA1YzIxOTEzODFlNWQyMjE4MzAyYzNiMDM5NTI0N2M5OTRmY2YwN2NlOA%3D%3D&v=1&f=sd", + "is_gif": False, + "transcoding_status": "completed", + } + }, + "is_video": True, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "Python", + "selftext": "", + "author_fullname": "t2_1kjpn251", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Concept Art: what might python look like in Japanese, without any English characters?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/Python", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "discussion", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hp7uqe", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.94, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1697, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Discussion", + "can_mod_post": False, + "score": 1697, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "ProgrammingLanguages", + "selftext": "", + "author_fullname": "t2_f4rdtgk", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Concept Art: what might python look like in Japanese, without any English characters?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/ProgrammingLanguages", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_g9iu8x", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.96, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 440, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Discussion", + "can_mod_post": False, + "score": 440, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1588088407, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ulc23n21jiv41.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "93811e06-0da7-11e8-a9a2-0e1129ea8e52", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qi8m", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "g9iu8x", + "is_robot_indexable": True, + "report_reasons": None, + "author": "MartialArtTetherball", + "discussion_type": None, + "num_comments": 65, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/ProgrammingLanguages/comments/g9iu8x/concept_art_what_might_python_look_like_in/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ulc23n21jiv41.png", + "subreddit_subscribers": 43859, + "created_utc": 1588059607, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594492194, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ulc23n21jiv41.png", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "0df42996-1c5e-11ea-b1a0-0e44e1c5b731", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh0y", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hp7uqe", + "is_robot_indexable": True, + "report_reasons": None, + "author": "SubstantialRange", + "discussion_type": None, + "num_comments": 182, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_g9iu8x", + "author_flair_text_color": None, + "permalink": "/r/Python/comments/hp7uqe/concept_art_what_might_python_look_like_in/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ulc23n21jiv41.png", + "subreddit_subscribers": 616297, + "created_utc": 1594463394, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hozdzo", + "before": None, + }, +} + +empty_mock = { + "kind": "Listing", + "data": { + "modhash": "y4he8gfzh9f892e2bf3094bc06daba2e02288e617fecf555b5", + "dist": 27, + "children": [], + "after": "t3_hozdzo", + "before": None, + }, +} diff --git a/src/newsreader/news/collection/tests/reddit/collector/tests.py b/src/newsreader/news/collection/tests/reddit/collector/tests.py new file mode 100644 index 0000000..1fd18b0 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/collector/tests.py @@ -0,0 +1,204 @@ +from datetime import datetime +from unittest.mock import patch +from uuid import uuid4 + +from django.test import TestCase +from django.utils import timezone + +import pytz + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamForbiddenException, + StreamNotFoundException, + StreamTimeOutException, +) +from newsreader.news.collection.reddit import RedditCollector +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.reddit.collector.mocks import ( + empty_mock, + simple_mock_1, + simple_mock_2, +) +from newsreader.news.core.models import Post + + +class RedditCollectorTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.patched_get = patch("newsreader.news.collection.reddit.fetch") + self.mocked_fetch = self.patched_get.start() + + self.patched_parse = patch( + "newsreader.news.collection.reddit.RedditStream.parse" + ) + self.mocked_parse = self.patched_parse.start() + + def tearDown(self): + patch.stopall() + + def test_simple_batch(self): + self.mocked_parse.side_effect = (simple_mock_1, simple_mock_2) + + rules = ( + (subreddit,) + for subreddit in SubredditFactory.create_batch( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + size=2, + ) + ) + + collector = RedditCollector() + collector.collect(rules=rules) + + self.assertCountEqual( + Post.objects.values_list("remote_identifier", flat=True), + ( + "hm6byg", + "hpkhgj", + "hph00n", + "hp9mlw", + "hpjn8x", + "gdfaip", + "hmd2ez", + "hpr28u", + "hpps6f", + "hp7uqe", + ), + ) + + for subreddit in rules: + with self.subTest(subreddit=subreddit): + self.assertEquals(subreddit.succeeded, True) + self.assertEquals(subreddit.last_suceeded, timezone.now()) + self.assertEquals(subreddit.error, None) + + post = Post.objects.get( + remote_identifier="hph00n", rule__type=RuleTypeChoices.subreddit + ) + + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 7, 11, 22, 23, 24)) + ) + + self.assertEquals(post.author, "HannahB888") + self.assertEquals( + post.title, "Drake Interplanetary Smartkey thing that I made!" + ) + self.assertEquals( + post.url, + "https://www.reddit.com/r/starcitizen/comments/hph00n/drake_interplanetary_smartkey_thing_that_i_made/", + ) + + post = Post.objects.get( + remote_identifier="hpr28u", rule__type=RuleTypeChoices.subreddit + ) + + self.assertEquals( + post.publication_date, pytz.utc.localize(datetime(2020, 7, 12, 10, 29, 10)) + ) + + self.assertEquals(post.author, "Sebaron") + self.assertEquals( + post.title, + "I am a medical student, and I recently programmed an open-source eye-tracker for brain research", + ) + self.assertEquals( + post.url, + "https://www.reddit.com/r/Python/comments/hpr28u/i_am_a_medical_student_and_i_recently_programmed/", + ) + + def test_empty_batch(self): + self.mocked_parse.side_effect = (empty_mock, empty_mock) + + rules = ( + (subreddit,) + for subreddit in SubredditFactory.create_batch( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + size=2, + ) + ) + + collector = RedditCollector() + collector.collect(rules=rules) + + self.assertEquals(Post.objects.count(), 0) + + for subreddit in rules: + with self.subTest(subreddit=subreddit): + self.assertEquals(subreddit.succeeded, True) + self.assertEquals(subreddit.last_suceeded, timezone.now()) + self.assertEquals(subreddit.error, None) + + def test_not_found(self): + self.mocked_fetch.side_effect = StreamNotFoundException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream not found") + + @patch("newsreader.news.collection.reddit.RedditTokenTask") + def test_denied(self, mocked_task): + self.mocked_fetch.side_effect = StreamDeniedException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream does not have sufficient permissions") + + mocked_task.delay.assert_called_once_with(rule.user.pk) + + def test_forbidden(self): + self.mocked_fetch.side_effect = StreamForbiddenException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream forbidden") + + def test_timed_out(self): + self.mocked_fetch.side_effect = StreamTimeOutException + + rule = SubredditFactory( + user__reddit_access_token=str(uuid4()), + user__reddit_refresh_token=str(uuid4()), + enabled=True, + ) + + collector = RedditCollector() + collector.collect(rules=((rule,),)) + + self.assertEquals(Post.objects.count(), 0) + self.assertEquals(rule.succeeded, False) + self.assertEquals(rule.error, "Stream timed out") diff --git a/src/newsreader/news/collection/tests/reddit/stream/__init__.py b/src/newsreader/news/collection/tests/reddit/stream/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/reddit/stream/mocks.py b/src/newsreader/news/collection/tests/reddit/stream/mocks.py new file mode 100644 index 0000000..148b31a --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/stream/mocks.py @@ -0,0 +1,3289 @@ +simple_mock = { + "kind": "Listing", + "data": { + "modhash": "sgq4fdizx94db5c05b57f9957a4b8b2d5e24b712f5a507cffd", + "dist": 27, + "children": [ + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.\n\nLet us know what's annoying you, whats making you happy, or something that you want to get out to r/linux but didn't make the cut into a full post of it's own.\n\nFor those looking for certifications please use this megathread to ask about how to get certified whether it's for the business world or for your own satisfaction. Be sure to check out r/linuxadmin for more discussion in the SysAdmin world!\n\n_Please keep questions in r/linuxquestions, r/linux4noobs, or the Wednesday automod thread._", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Experiences/Rants or Education/Certifications thread - July 06, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hm0qct", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.65, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 6, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 6, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594037482.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a> rants and experiences! This megathread is also to hear opinions from anyone just starting out with Linux or those that have used Linux (GNU or otherwise) for a long time.</p>\n\n<p>Let us know what&#39;s annoying you, whats making you happy, or something that you want to get out to <a href="/r/linux">r/linux</a> but didn&#39;t make the cut into a full post of it&#39;s own.</p>\n\n<p>For those looking for certifications please use this megathread to ask about how to get certified whether it&#39;s for the business world or for your own satisfaction. Be sure to check out <a href="/r/linuxadmin">r/linuxadmin</a> for more discussion in the SysAdmin world!</p>\n\n<p><em>Please keep questions in <a href="/r/linuxquestions">r/linuxquestions</a>, <a href="/r/linux4noobs">r/linux4noobs</a>, or the Wednesday automod thread.</em></p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hm0qct", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 8, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hm0qct/linux_experiencesrants_or_educationcertifications/", + "subreddit_subscribers": 543995, + "created_utc": 1594008682.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Welcome to r/linux! If you're new to Linux or trying to get started this thread is for you. Get help here or as always, check out r/linuxquestions or r/linux4noobs\n\nThis megathread is for all your question needs. As we don't allow questions on r/linux outside of this megathread, please consider using r/linuxquestions or r/linux4noobs for the best solution to your problem.\n\nAsk your hardware requests here too or try r/linuxhardware!", + "author_fullname": "t2_6l4z3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Weekly Questions and Hardware Thread - July 08, 2020", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hna75r", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.5, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 0, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 0, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594210138.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Welcome to <a href="/r/linux">r/linux</a>! If you&#39;re new to Linux or trying to get started this thread is for you. Get help here or as always, check out <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a></p>\n\n<p>This megathread is for all your question needs. As we don&#39;t allow questions on <a href="/r/linux">r/linux</a> outside of this megathread, please consider using <a href="/r/linuxquestions">r/linuxquestions</a> or <a href="/r/linux4noobs">r/linux4noobs</a> for the best solution to your problem.</p>\n\n<p>Ask your hardware requests here too or try <a href="/r/linuxhardware">r/linuxhardware</a>!</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": "new", + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": "moderator", + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hna75r", + "is_robot_indexable": True, + "report_reasons": None, + "author": "AutoModerator", + "discussion_type": None, + "num_comments": 2, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "parent_whitelist_status": "all_ads", + "stickied": True, + "url": "https://www.reddit.com/r/linux/comments/hna75r/weekly_questions_and_hardware_thread_july_08_2020/", + "subreddit_subscribers": 543995, + "created_utc": 1594181338.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_gr7k5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Here's a feature Linux could borrow from BSD: in-kernel debugger with built-in hangman game", + "link_flair_richtext": [{"e": "text", "t": "Fluff"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngs71", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.9, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 135, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Fluff", + "can_mod_post": False, + "score": 135, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242629.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/wmc8tp2ium951.jpg", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "af8918be-6777-11e7-8273-0e925d908786", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#9a2bff", + "id": "hngs71", + "is_robot_indexable": True, + "report_reasons": None, + "author": "the_humeister", + "discussion_type": None, + "num_comments": 20, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hngs71/heres_a_feature_linux_could_borrow_from_bsd/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/wmc8tp2ium951.jpg", + "subreddit_subscribers": 543995, + "created_utc": 1594213829.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_k9f35", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "KeePassXC 2.6.0 released", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngsj8", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.97, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 126, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 126, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242666.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "keepassxc.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hngsj8", + "is_robot_indexable": True, + "report_reasons": None, + "author": "nixcraft", + "discussion_type": None, + "num_comments": 42, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hngsj8/keepassxc_260_released/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://keepassxc.org/blog/2020-07-07-2.6.0-released/", + "subreddit_subscribers": 543995, + "created_utc": 1594213866.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd7cy", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.95, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 223, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 223, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "libreoffice", + "selftext": "", + "author_fullname": "t2_hlv0o", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": 'Board statement on the LibreOffice 7.0 RC "Personal Edition" label', + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/libreoffice", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnd6yo", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.94, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 28, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 28, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594224961.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "dc82ac98-bafb-11e4-9f88-22000b310327", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2s4nt", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnd6yo", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 38, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/libreoffice/comments/hnd6yo/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 4669, + "created_utc": 1594196161.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + } + ], + "created": 1594225018.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.documentfoundation.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnd7cy", + "is_robot_indexable": True, + "report_reasons": None, + "author": "TheQuantumZero", + "discussion_type": None, + "num_comments": 109, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnd6yo", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnd7cy/board_statement_on_the_libreoffice_70_rc_personal/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.documentfoundation.org/blog/2020/07/06/board-statement-on-the-libreoffice-7-0rc-personal-edition-label/", + "subreddit_subscribers": 543995, + "created_utc": 1594196218.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_6cxnzaq2", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Gentoo Now on Android Platform !!!", + "link_flair_richtext": [{"e": "text", "t": "Mobile Linux"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnemei", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.87, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 78, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "a54a7460-cdf6-11e8-b31c-0e89679a2148", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Mobile Linux", + "can_mod_post": False, + "score": 78, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":arch:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/tip79drnqpr11_t5_2qh1a/arch", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594232773.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "gentoo.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://www.gentoo.org/news/2020/07/07/gentoo-android.html", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "84162644-5859-11e8-b9ed-0efda312d094", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":arch:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#d78216", + "id": "hnemei", + "is_robot_indexable": True, + "report_reasons": None, + "author": "draplon", + "discussion_type": None, + "num_comments": 21, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hnemei/gentoo_now_on_android_platform/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.gentoo.org/news/2020/07/07/gentoo-android.html", + "subreddit_subscribers": 543995, + "created_utc": 1594203973.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_f9vxe", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Google is teaming up with Ubuntu to bring Flutter apps to Linux", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hniojf", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.77, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 31, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 31, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594249580.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "androidpolice.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://www.androidpolice.com/2020/07/08/google-is-teaming-up-with-ubuntu-to-bring-flutter-apps-to-linux/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hniojf", + "is_robot_indexable": True, + "report_reasons": None, + "author": "bilal4hmed", + "discussion_type": None, + "num_comments": 24, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hniojf/google_is_teaming_up_with_ubuntu_to_bring_flutter/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.androidpolice.com/2020/07/08/google-is-teaming-up-with-ubuntu-to-bring-flutter-apps-to-linux/", + "subreddit_subscribers": 543995, + "created_utc": 1594220780.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_k9f35", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Ariane RISC-V CPU \u2013 An open source CPU capable of booting Linux", + "link_flair_richtext": [{"e": "text", "t": "Hardware"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hngr1j", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.89, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 49, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Hardware", + "can_mod_post": False, + "score": 49, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594242511.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "github.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://github.com/openhwgroup/cva6", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "3d48793a-c823-11e8-9a58-0ee3c97eb952", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#cc5289", + "id": "hngr1j", + "is_robot_indexable": True, + "report_reasons": None, + "author": "nixcraft", + "discussion_type": None, + "num_comments": 15, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hngr1j/ariane_riscv_cpu_an_open_source_cpu_capable_of/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://github.com/openhwgroup/cva6", + "subreddit_subscribers": 543995, + "created_utc": 1594213711.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_6kt9ukjs", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Canonical enables Linux desktop app support with Flutter", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnj1ap", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.79, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 24, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 24, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594250752.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "ubuntu.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://ubuntu.com/blog/canonical-enables-linux-desktop-app-support-with-flutter", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hnj1ap", + "is_robot_indexable": True, + "report_reasons": None, + "author": "hmblhstl", + "discussion_type": None, + "num_comments": 28, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnj1ap/canonical_enables_linux_desktop_app_support_with/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://ubuntu.com/blog/canonical-enables-linux-desktop-app-support-with-flutter", + "subreddit_subscribers": 543995, + "created_utc": 1594221952.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_3vf8x", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Sandboxing in Linux with zero lines of code", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnfzbm", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.83, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 30, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 30, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594239285.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "blog.cloudflare.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://blog.cloudflare.com/sandboxing-in-linux-with-zero-lines-of-code/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnfzbm", + "is_robot_indexable": True, + "report_reasons": None, + "author": "pimterry", + "discussion_type": None, + "num_comments": 0, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnfzbm/sandboxing_in_linux_with_zero_lines_of_code/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://blog.cloudflare.com/sandboxing-in-linux-with-zero-lines-of-code/", + "subreddit_subscribers": 543995, + "created_utc": 1594210485.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_318in", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "SUSE Enters Into Definitive Agreement to Acquire Rancher Labs", + "link_flair_richtext": [ + {"e": "text", "t": "Open Source Organization"} + ], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnh5ux", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.84, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 26, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Open Source Organization", + "can_mod_post": False, + "score": 26, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594244123.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "rancher.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://rancher.com/blog/2020/suse-to-acquire-rancher/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "8a1dd4b0-5859-11e8-a2c7-0e5ebdbe24d6", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#800000", + "id": "hnh5ux", + "is_robot_indexable": True, + "report_reasons": None, + "author": "hjames9", + "discussion_type": None, + "num_comments": 5, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnh5ux/suse_enters_into_definitive_agreement_to_acquire/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://rancher.com/blog/2020/suse-to-acquire-rancher/", + "subreddit_subscribers": 543995, + "created_utc": 1594215323.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_j1a5", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Mint drops Ubuntu Snap packages [LWN.net]", + "link_flair_richtext": [{"e": "text", "t": "Distro News"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnlt4l", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.8, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 9, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Distro News", + "can_mod_post": False, + "score": 9, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594259641.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "lwn.net", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://lwn.net/SubscriberLink/825005/6440c82feb745bbe/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "6888e772-5859-11e8-82ff-0e816ab71260", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0dd3bb", + "id": "hnlt4l", + "is_robot_indexable": True, + "report_reasons": None, + "author": "tapo", + "discussion_type": None, + "num_comments": 3, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnlt4l/linux_mint_drops_ubuntu_snap_packages_lwnnet/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://lwn.net/SubscriberLink/825005/6440c82feb745bbe/", + "subreddit_subscribers": 543995, + "created_utc": 1594230841.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_4i3yk", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Announcing Flutter Linux Alpha with Canonical", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hniq04", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.6, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 6, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 6, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594249712.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "medium.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://medium.com/flutter/announcing-flutter-linux-alpha-with-canonical-19eb824590a9", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hniq04", + "is_robot_indexable": True, + "report_reasons": None, + "author": "popeydc", + "discussion_type": None, + "num_comments": 3, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hniq04/announcing_flutter_linux_alpha_with_canonical/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://medium.com/flutter/announcing-flutter-linux-alpha-with-canonical-19eb824590a9", + "subreddit_subscribers": 543995, + "created_utc": 1594220912.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_611c0ard", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "New anti-encryption bill worse than EARN IT, would force a backdoor into any US device/software. Act now to stop both.", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmp66i", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.98, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 3340, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 3340, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594131589.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "tutanota.com", + "allow_live_comments": True, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://tutanota.com/blog/posts/lawful-access-encrypted-data-act-backdoor", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hmp66i", + "is_robot_indexable": True, + "report_reasons": None, + "author": "fossfans", + "discussion_type": None, + "num_comments": 380, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmp66i/new_antiencryption_bill_worse_than_earn_it_would/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://tutanota.com/blog/posts/lawful-access-encrypted-data-act-backdoor", + "subreddit_subscribers": 543995, + "created_utc": 1594102789.0, + "num_crossposts": 2, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "We have had Freesync \"support\" for quite some time now, but it is extremely restrictive and very picky to get it working. Just the requirements to have Freesync working is no-go for many:\n\n\\-> Single monitor only;\n\n\\-> No video playback or turning it on while on desktop;\n\n\\-> Should only be turned on only while the game/software in question is in fullscreen;\n\n\\-> X11, no Wayland;\n\n\\-> Only tested/working distro is Ubuntu 16.04.3;\n\n\\-> Need of setting it up through some quite cryptic commands;\n\n\\-> Doesn't work after hotplug or system restart;\n\n\\-> No Freesync over HDMI (which isn't a massive problem, but a nice option to have);\n\n\\-> Apparently only OpenGL, no Vulkan (Steam Play/Proton, which is the main purpose for Freesync at the moment, doesn't work);\n\n&#x200B;\n\nI am not really complaining, because I do know that Freesync is hard to get working on Linux, but we have had so many advancements on the gaming side of Linux, and we are still stuck with all of these restrictions to use Freesync, which is quite a useful functionality for almost every gamer. If Mozilla got video decoding working well on Wayland, I hope (Idk anything about this, just hoping) that it could also be easy to implement Freesync on Wayland too.\n\nWe just haven't had that many improvements on this side of the Linux gaming world, and I'd like to know if it is lack of support/interest by AMD, or if it actually is extremely hard to implement it on Linux. Freesync would also be very useful for those who are running monitors over 60Hz, so that those 60FPS videos don't look as weird as they do while playing back on higher refresh rate monitors. It is just a nice thing for everybody, really!", + "author_fullname": "t2_1afv9v8g", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Any evolution on the Freesync situation on Linux?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn7agp", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.85, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 83, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "fa602e36-cdf6-11e8-93c9-0e41ac35f4cc", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 83, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":ubuntu:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/uwmddx7qqpr11_t5_2qh1a/ubuntu", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594199056.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>We have had Freesync &quot;support&quot; for quite some time now, but it is extremely restrictive and very picky to get it working. Just the requirements to have Freesync working is no-go for many:</p>\n\n<p>-&gt; Single monitor only;</p>\n\n<p>-&gt; No video playback or turning it on while on desktop;</p>\n\n<p>-&gt; Should only be turned on only while the game/software in question is in fullscreen;</p>\n\n<p>-&gt; X11, no Wayland;</p>\n\n<p>-&gt; Only tested/working distro is Ubuntu 16.04.3;</p>\n\n<p>-&gt; Need of setting it up through some quite cryptic commands;</p>\n\n<p>-&gt; Doesn&#39;t work after hotplug or system restart;</p>\n\n<p>-&gt; No Freesync over HDMI (which isn&#39;t a massive problem, but a nice option to have);</p>\n\n<p>-&gt; Apparently only OpenGL, no Vulkan (Steam Play/Proton, which is the main purpose for Freesync at the moment, doesn&#39;t work);</p>\n\n<p>&#x200B;</p>\n\n<p>I am not really complaining, because I do know that Freesync is hard to get working on Linux, but we have had so many advancements on the gaming side of Linux, and we are still stuck with all of these restrictions to use Freesync, which is quite a useful functionality for almost every gamer. If Mozilla got video decoding working well on Wayland, I hope (Idk anything about this, just hoping) that it could also be easy to implement Freesync on Wayland too.</p>\n\n<p>We just haven&#39;t had that many improvements on this side of the Linux gaming world, and I&#39;d like to know if it is lack of support/interest by AMD, or if it actually is extremely hard to implement it on Linux. Freesync would also be very useful for those who are running monitors over 60Hz, so that those 60FPS videos don&#39;t look as weird as they do while playing back on higher refresh rate monitors. It is just a nice thing for everybody, really!</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":ubuntu:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hn7agp", + "is_robot_indexable": True, + "report_reasons": None, + "author": "mreich98", + "discussion_type": None, + "num_comments": 36, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hn7agp/any_evolution_on_the_freesync_situation_on_linux/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hn7agp/any_evolution_on_the_freesync_situation_on_linux/", + "subreddit_subscribers": 543995, + "created_utc": 1594170256.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_7ccf", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Running Rosetta@home on a Raspberry Pi with Fedora IoT", + "link_flair_richtext": [{"e": "text", "t": "Popular Application"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnfw0h", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.73, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 8, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Popular Application", + "can_mod_post": False, + "score": 8, + "approved_by": None, + "author_premium": True, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594238884.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "fedoramagazine.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://fedoramagazine.org/running-rosettahome-on-a-raspberry-pi-with-fedora-iot/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7127ec98-5859-11e8-9488-0e8717893ec8", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#0aa18f", + "id": "hnfw0h", + "is_robot_indexable": True, + "report_reasons": None, + "author": "speckz", + "discussion_type": None, + "num_comments": 1, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnfw0h/running_rosettahome_on_a_raspberry_pi_with_fedora/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://fedoramagazine.org/running-rosettahome-on-a-raspberry-pi-with-fedora-iot/", + "subreddit_subscribers": 543995, + "created_utc": 1594210084.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_sx11s", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Getting Things GNOME 0.4 released! First release in almost 7 years (Flatpak available).", + "link_flair_richtext": [{"e": "text", "t": "Software Release"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn5wh6", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.79, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 58, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "2194c338-ce1d-11e8-8ed7-0e20bb1bbc52", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Software Release", + "can_mod_post": False, + "score": 58, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [ + { + "a": ":nix:", + "e": "emoji", + "u": "https://emoji.redditmedia.com/ww1ubcjpqpr11_t5_2qh1a/nix", + } + ], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594193982.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "flathub.org", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://flathub.org/apps/details/org.gnome.GTG", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "904ea3e4-6748-11e7-b925-0ef3dfbb807a", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": ":nix:", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#349e48", + "id": "hn5wh6", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Kanarme", + "discussion_type": None, + "num_comments": 22, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/linux/comments/hn5wh6/getting_things_gnome_04_released_first_release_in/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://flathub.org/apps/details/org.gnome.GTG", + "subreddit_subscribers": 543995, + "created_utc": 1594165182.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_636xx258", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "mpv is not anymore supporting gnome. and the owner reverted the commit again shortly after and then again made a new one, to add the changes", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnnt0v", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 1.0, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 1, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "gnome", + "selftext": "", + "author_fullname": "t2_33wgs4m3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "mpv is not anymore supporting gnome. and the owner reverted the commit again shortly after and then again made a new one, to add the changes", + "link_flair_richtext": [{"e": "text", "t": "News"}], + "subreddit_name_prefixed": "r/gnome", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn1s3r", + "quarantine": False, + "link_flair_text_color": "light", + "upvote_ratio": 0.81, + "author_flair_background_color": "transparent", + "subreddit_type": "public", + "ups": 23, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": "1515012e-bed8-11ea-92a7-0eb4e155a177", + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "News", + "can_mod_post": False, + "score": 23, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": "gnome-user", + "author_flair_richtext": [{"e": "text", "t": "GNOMie"}], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594180508.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "richtext", + "domain": "github.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": "confidence", + "banned_at_utc": None, + "url_overridden_by_dest": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "7dbe0c80-f9df-11e8-b35e-0e2ae22a2534", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": "GNOMie", + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qjhn", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#692c52", + "id": "hn1s3r", + "is_robot_indexable": True, + "report_reasons": None, + "author": "idiot10000000", + "discussion_type": None, + "num_comments": 53, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": "dark", + "permalink": "/r/gnome/comments/hn1s3r/mpv_is_not_anymore_supporting_gnome_and_the_owner/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "subreddit_subscribers": 41350, + "created_utc": 1594151708.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + } + ], + "created": 1594265700.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "github.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnnt0v", + "is_robot_indexable": True, + "report_reasons": None, + "author": "RetartedTortoise", + "discussion_type": None, + "num_comments": 0, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hn1s3r", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnnt0v/mpv_is_not_anymore_supporting_gnome_and_the_owner/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://github.com/mpv-player/mpv/commit/cdaa496314f90412963f2b3211e18df72910066d#commitcomment-40434556", + "subreddit_subscribers": 543995, + "created_utc": 1594236900.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_21omsw7y", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Google and Canonical bring Linux apps support to Flutter - 9to5Google", + "link_flair_richtext": [{"e": "text", "t": "Development"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnj42j", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.59, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 3, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Development", + "can_mod_post": False, + "score": 3, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594251002.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "9to5google.com", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://9to5google.com/2020/07/08/google-canonical-partnership-linux-flutter-apps/", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "3cb511e2-7914-11ea-bb33-0ee30ee9d22b", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#f0db8a", + "id": "hnj42j", + "is_robot_indexable": True, + "report_reasons": None, + "author": "satvikpendem", + "discussion_type": None, + "num_comments": 1, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnj42j/google_and_canonical_bring_linux_apps_support_to/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://9to5google.com/2020/07/08/google-canonical-partnership-linux-flutter-apps/", + "subreddit_subscribers": 543995, + "created_utc": 1594222202.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": " As far as I understand it, the current options on the Intel Iris/NVIDIA side are:\n\n* Intel or NVIDIA cards only\n\n* Optimus for switching between Intel and Intel+NVIDIA (requires reboot)\n\n* Bumblebee for on-the-fly switching with a performance hit\n\n* nvidia-xrun, which does everything bumblebee can do but requires a second X server\n\n* Prime Rener Offload, a proprietary NVIDIA thing, for switching between Intel and Intel+NVIDIA, which I don't completely understand\n\nDo I have this right? And how do things look on the Amd Vega/Radeon configuration?", + "author_fullname": "t2_tcnt4", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "[Discussion] What's the current status on laptop switchable graphics?", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnmiik", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.67, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 1, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 1, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594261813.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>As far as I understand it, the current options on the Intel Iris/NVIDIA side are:</p>\n\n<ul>\n<li><p>Intel or NVIDIA cards only</p></li>\n<li><p>Optimus for switching between Intel and Intel+NVIDIA (requires reboot)</p></li>\n<li><p>Bumblebee for on-the-fly switching with a performance hit</p></li>\n<li><p>nvidia-xrun, which does everything bumblebee can do but requires a second X server</p></li>\n<li><p>Prime Rener Offload, a proprietary NVIDIA thing, for switching between Intel and Intel+NVIDIA, which I don&#39;t completely understand</p></li>\n</ul>\n\n<p>Do I have this right? And how do things look on the Amd Vega/Radeon configuration?</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnmiik", + "is_robot_indexable": True, + "report_reasons": None, + "author": "KoolDude214", + "discussion_type": None, + "num_comments": 4, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnmiik/discussion_whats_the_current_status_on_laptop/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hnmiik/discussion_whats_the_current_status_on_laptop/", + "subreddit_subscribers": 543995, + "created_utc": 1594233013.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Hello all!\n\nI've created this simple web app as a part of learning web development, to help people select a linux distro for themselves.\n\nIt's a really simple web app, as I've created it as part of learning web development.\n\nIt retrieves data from another API that I've defined and this very API's database is used to store all the releated information that only right now I can store.\n\nAnd this web app is used to get information from that API and display it in an organized way.\n\nHave a look and please let me know about your thoughts and suggestions:\n\nLink: [https://linux-distros-encyclopedia.herokuapp.com/](https://linux-distros-encyclopedia.herokuapp.com/)", + "author_fullname": "t2_4c9tcvx3", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Distributions Encyclopedia Web App", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnlh54", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.5, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 0, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 0, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594258586.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Hello all!</p>\n\n<p>I&#39;ve created this simple web app as a part of learning web development, to help people select a linux distro for themselves.</p>\n\n<p>It&#39;s a really simple web app, as I&#39;ve created it as part of learning web development.</p>\n\n<p>It retrieves data from another API that I&#39;ve defined and this very API&#39;s database is used to store all the releated information that only right now I can store.</p>\n\n<p>And this web app is used to get information from that API and display it in an organized way.</p>\n\n<p>Have a look and please let me know about your thoughts and suggestions:</p>\n\n<p>Link: <a href="https://linux-distros-encyclopedia.herokuapp.com/">https://linux-distros-encyclopedia.herokuapp.com/</a></p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnlh54", + "is_robot_indexable": True, + "report_reasons": None, + "author": "MisterKhJe", + "discussion_type": None, + "num_comments": 2, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnlh54/linux_distributions_encyclopedia_web_app/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hnlh54/linux_distributions_encyclopedia_web_app/", + "subreddit_subscribers": 543995, + "created_utc": 1594229786.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "I would like to turn my old Asus tablet into an ultimate linux-based Ebook reader. It's currently running kali linux due to my netsec background and I can't say that it runs flawlessly. The tablet came with Windows 10 by default. Does anyone have the experience with what distro and pdf reader to use?\n\nIt has to be lightweight due to 1.3Ghz Atom processor and 1Gb of Ram.", + "author_fullname": "t2_y0rlp", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux based Ebook reader tablet", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hnecim", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.56, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 2, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 2, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594231304.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>I would like to turn my old Asus tablet into an ultimate linux-based Ebook reader. It&#39;s currently running kali linux due to my netsec background and I can&#39;t say that it runs flawlessly. The tablet came with Windows 10 by default. Does anyone have the experience with what distro and pdf reader to use?</p>\n\n<p>It has to be lightweight due to 1.3Ghz Atom processor and 1Gb of Ram.</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnecim", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Kikur", + "discussion_type": None, + "num_comments": 5, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnecim/linux_based_ebook_reader_tablet/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hnecim/linux_based_ebook_reader_tablet/", + "subreddit_subscribers": 543995, + "created_utc": 1594202504.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_300vb", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Backing up my work-provided Windows laptop with Debian, ZFS and SquashFS", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hn2ro8", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.74, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 23, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 23, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594183686.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "thanassis.space", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://www.thanassis.space/backupCOVID.html", + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hn2ro8", + "is_robot_indexable": True, + "report_reasons": None, + "author": "ttsiodras", + "discussion_type": None, + "num_comments": 5, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hn2ro8/backing_up_my_workprovided_windows_laptop_with/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.thanassis.space/backupCOVID.html", + "subreddit_subscribers": 543995, + "created_utc": 1594154886.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "", + "author_fullname": "t2_2ccbdhht", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Debian influences everywhere", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnndj2", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.36, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 0, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 0, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "crosspost_parent_list": [ + { + "approved_at_utc": None, + "subreddit": "ramen", + "selftext": "", + "author_fullname": "t2_1e5jztuf", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "My 1st Attempt for Tori Paitan", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/ramen", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": True, + "name": "t3_hnn89u", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 1.0, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 2, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": True, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Homemade", + "can_mod_post": False, + "score": 2, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": False, + "mod_note": None, + "created": 1594263979.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ai9r2wu5mo951.jpg", + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "28b48e48-ce25-11e8-94f2-0e1ed223bf48", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qykd", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#ffd635", + "id": "hnn89u", + "is_robot_indexable": True, + "report_reasons": None, + "author": "cheesychicken80", + "discussion_type": None, + "num_comments": 1, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/ramen/comments/hnn89u/my_1st_attempt_for_tori_paitan/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ai9r2wu5mo951.jpg", + "subreddit_subscribers": 257000, + "created_utc": 1594235179.0, + "num_crossposts": 1, + "media": None, + "is_video": False, + } + ], + "created": 1594264403.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "i.redd.it", + "allow_live_comments": False, + "selftext_html": None, + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "url_overridden_by_dest": "https://i.redd.it/ai9r2wu5mo951.jpg", + "view_count": None, + "archived": False, + "no_follow": True, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hnndj2", + "is_robot_indexable": True, + "report_reasons": None, + "author": "dracardOner", + "discussion_type": None, + "num_comments": 0, + "send_replies": False, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "crosspost_parent": "t3_hnn89u", + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hnndj2/debian_influences_everywhere/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://i.redd.it/ai9r2wu5mo951.jpg", + "subreddit_subscribers": 543995, + "created_utc": 1594235603.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "There is an open issue in Electron-Builder to add option to easily create flatpak repo. This results in many electron apps not officially/easily supporting flatpak, thus solving this would help flatpak adoption and make it easier for users to install their favourite apps. See the issue on github for more info [https://github.com/electron-userland/electron-builder/issues/512](https://github.com/electron-userland/electron-builder/issues/512)\n\nSince there are no technical obstacles that prevent completing this task, I made a small bounty on gitpay [https://gitpay.me/#/task/352](https://gitpay.me/#/task/352) to motivate developers, and if you care about this issue, consider chiming in too, spreading the word or even giving a try at implementing this :)", + "author_fullname": "t2_5hgjidqm", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Crowdsource Flatpak support in Electron-Builder", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmytic", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.76, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 37, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 37, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594171301.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>There is an open issue in Electron-Builder to add option to easily create flatpak repo. This results in many electron apps not officially/easily supporting flatpak, thus solving this would help flatpak adoption and make it easier for users to install their favourite apps. See the issue on github for more info <a href="https://github.com/electron-userland/electron-builder/issues/512">https://github.com/electron-userland/electron-builder/issues/512</a></p>\n\n<p>Since there are no technical obstacles that prevent completing this task, I made a small bounty on gitpay <a href="https://gitpay.me/#/task/352">https://gitpay.me/#/task/352</a> to motivate developers, and if you care about this issue, consider chiming in too, spreading the word or even giving a try at implementing this :)</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hmytic", + "is_robot_indexable": True, + "report_reasons": None, + "author": "ignapk", + "discussion_type": None, + "num_comments": 23, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmytic/crowdsource_flatpak_support_in_electronbuilder/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hmytic/crowdsource_flatpak_support_in_electronbuilder/", + "subreddit_subscribers": 543995, + "created_utc": 1594142501.0, + "num_crossposts": 5, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "I was experiencing graphic issues and glitches in some games while using Linux Ubuntu 20.04 LTS with my Ryzen 3 3250u CPU and I wanted to share how I fixed this issue for anyone else with this same problem.\n\nFirst thing you should try is setting 'AMD_DEBUG=nodmacopyimage' as an environmental variable. This only partly fixed the issue for me as most of the in-game textures were still glitchy and messed up. However this method seemed to work for some other people https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814\n\nThe second method I tried was downgrading from Ubuntu 20.04 to Ubuntu 19.10. This fixed my problem instantly and the glitchy in-game textures were no longer an issue.\n\n\nIm still new to Linux and not very tech savvy so I can't provide a detailed explanation of what causes this problem and why these methods seem to fix it however I'm pretty sure its something to do with the AMD graphics drivers. Hopefully this issue will be fixed in the next Ubuntu update\n\nHope this helped ;)", + "author_fullname": "t2_6qntnayu", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Linux Graphical Glitches on Ryzen CPUs", + "link_flair_richtext": [{"e": "text", "t": "Tips and Tricks"}], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": "", + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmxiyt", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.79, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 20, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": "Tips and Tricks", + "can_mod_post": False, + "score": 20, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594167246.0, + "link_flair_type": "richtext", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>I was experiencing graphic issues and glitches in some games while using Linux Ubuntu 20.04 LTS with my Ryzen 3 3250u CPU and I wanted to share how I fixed this issue for anyone else with this same problem.</p>\n\n<p>First thing you should try is setting &#39;AMD_DEBUG=nodmacopyimage&#39; as an environmental variable. This only partly fixed the issue for me as most of the in-game textures were still glitchy and messed up. However this method seemed to work for some other people <a href="https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814">https://gitlab.freedesktop.org/mesa/mesa/-/issues/2814</a></p>\n\n<p>The second method I tried was downgrading from Ubuntu 20.04 to Ubuntu 19.10. This fixed my problem instantly and the glitchy in-game textures were no longer an issue.</p>\n\n<p>Im still new to Linux and not very tech savvy so I can&#39;t provide a detailed explanation of what causes this problem and why these methods seem to fix it however I&#39;m pretty sure its something to do with the AMD graphics drivers. Hopefully this issue will be fixed in the next Ubuntu update</p>\n\n<p>Hope this helped ;)</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "link_flair_template_id": "de62f716-76df-11ea-802c-0e7469f68f6b", + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "#00a6a5", + "id": "hmxiyt", + "is_robot_indexable": True, + "report_reasons": None, + "author": "Inolicious_", + "discussion_type": None, + "num_comments": 9, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmxiyt/linux_graphical_glitches_on_ryzen_cpus/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hmxiyt/linux_graphical_glitches_on_ryzen_cpus/", + "subreddit_subscribers": 543995, + "created_utc": 1594138446.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + { + "kind": "t3", + "data": { + "approved_at_utc": None, + "subreddit": "linux", + "selftext": "Well, alright, at various points in my life I may have been more pleased, but Windows has been losing my support for years one small nitpick at a time. Just wanted to share the change for whoever cares.\n\n* I liked the look and massive size of the windows less and less \n* As a programmer using bash and zsh on cygwin became more and more annoying\n* Windows keeps randomly changing stuff that I never wanted, like my downloads folder becoming a date-sorted list instead of an actual folder (and switching it back when I changed it!)\n* Adding cortana and the like and making it difficult to disable\n* Windows update.\n* Almost every bit of software I have at this point is also on linux or through a browser!\n\nI switched to Manjaro-Gnome and never looked back.\n\n* It's sleeker/runs faster.\n* Uses less RAM\n* Uses rolling updates\n* I can finally just use a built-in terminal\n* Has an easier to understand file structure, despite its complexity.\n* Is surprisingly easy to use. The only difficult part really was finding the wifi driver, and that was actually because it was mislabeled by the manufacturer.\n* Gnome is definitely nicer to use than Windows 10.\n* Searching for files and programs works well! I really didn't need windows to fail to find a program I had installed and instead offer to search for it online... through Bing on Edge.\n\nI never knew how much bloat Windows had until I switched over. This is so damn nice. I don't know why I didn't consider Linux as a serious alternative until recently. Steam Proton has also come a long, long way, I haven't had issues with a game yet.\n\nAnyways, I just wanted to rant, and I'm probably going to install an Manjaro-xfce on a bunch of old laptops.", + "author_fullname": "t2_8zm4y", + "saved": False, + "mod_reason_title": None, + "gilded": 0, + "clicked": False, + "title": "Switched from Windows 10 to Manjaro, never been happier", + "link_flair_richtext": [], + "subreddit_name_prefixed": "r/linux", + "hidden": False, + "pwls": 6, + "link_flair_css_class": None, + "downs": 0, + "top_awarded_type": None, + "hide_score": False, + "name": "t3_hmgujt", + "quarantine": False, + "link_flair_text_color": "dark", + "upvote_ratio": 0.92, + "author_flair_background_color": None, + "subreddit_type": "public", + "ups": 598, + "total_awards_received": 0, + "media_embed": {}, + "author_flair_template_id": None, + "is_original_content": False, + "user_reports": [], + "secure_media": None, + "is_reddit_media_domain": False, + "is_meta": False, + "category": None, + "secure_media_embed": {}, + "link_flair_text": None, + "can_mod_post": False, + "score": 598, + "approved_by": None, + "author_premium": False, + "thumbnail": "", + "edited": False, + "author_flair_css_class": None, + "author_flair_richtext": [], + "gildings": {}, + "content_categories": None, + "is_self": True, + "mod_note": None, + "created": 1594099445.0, + "link_flair_type": "text", + "wls": 6, + "removed_by_category": None, + "banned_by": None, + "author_flair_type": "text", + "domain": "self.linux", + "allow_live_comments": False, + "selftext_html": '<!-- SC_OFF --><div class="md"><p>Well, alright, at various points in my life I may have been more pleased, but Windows has been losing my support for years one small nitpick at a time. Just wanted to share the change for whoever cares.</p>\n\n<ul>\n<li>I liked the look and massive size of the windows less and less </li>\n<li>As a programmer using bash and zsh on cygwin became more and more annoying</li>\n<li>Windows keeps randomly changing stuff that I never wanted, like my downloads folder becoming a date-sorted list instead of an actual folder (and switching it back when I changed it!)</li>\n<li>Adding cortana and the like and making it difficult to disable</li>\n<li>Windows update.</li>\n<li>Almost every bit of software I have at this point is also on linux or through a browser!</li>\n</ul>\n\n<p>I switched to Manjaro-Gnome and never looked back.</p>\n\n<ul>\n<li>It&#39;s sleeker/runs faster.</li>\n<li>Uses less RAM</li>\n<li>Uses rolling updates</li>\n<li>I can finally just use a built-in terminal</li>\n<li>Has an easier to understand file structure, despite its complexity.</li>\n<li>Is surprisingly easy to use. The only difficult part really was finding the wifi driver, and that was actually because it was mislabeled by the manufacturer.</li>\n<li>Gnome is definitely nicer to use than Windows 10.</li>\n<li>Searching for files and programs works well! I really didn&#39;t need windows to fail to find a program I had installed and instead offer to search for it online... through Bing on Edge.</li>\n</ul>\n\n<p>I never knew how much bloat Windows had until I switched over. This is so damn nice. I don&#39;t know why I didn&#39;t consider Linux as a serious alternative until recently. Steam Proton has also come a long, long way, I haven&#39;t had issues with a game yet.</p>\n\n<p>Anyways, I just wanted to rant, and I&#39;m probably going to install an Manjaro-xfce on a bunch of old laptops.</p>\n</div><!-- SC_ON -->', + "likes": None, + "suggested_sort": None, + "banned_at_utc": None, + "view_count": None, + "archived": False, + "no_follow": False, + "is_crosspostable": True, + "pinned": False, + "over_18": False, + "all_awardings": [], + "awarders": [], + "media_only": False, + "can_gild": True, + "spoiler": False, + "locked": False, + "author_flair_text": None, + "treatment_tags": [], + "visited": False, + "removed_by": None, + "num_reports": None, + "distinguished": None, + "subreddit_id": "t5_2qh1a", + "mod_reason_by": None, + "removal_reason": None, + "link_flair_background_color": "", + "id": "hmgujt", + "is_robot_indexable": True, + "report_reasons": None, + "author": "ForShotgun", + "discussion_type": None, + "num_comments": 213, + "send_replies": True, + "whitelist_status": "all_ads", + "contest_mode": False, + "mod_reports": [], + "author_patreon_flair": False, + "author_flair_text_color": None, + "permalink": "/r/linux/comments/hmgujt/switched_from_windows_10_to_manjaro_never_been/", + "parent_whitelist_status": "all_ads", + "stickied": False, + "url": "https://www.reddit.com/r/linux/comments/hmgujt/switched_from_windows_10_to_manjaro_never_been/", + "subreddit_subscribers": 543995, + "created_utc": 1594070645.0, + "num_crossposts": 0, + "media": None, + "is_video": False, + }, + }, + ], + "after": "t3_hmgujt", + "before": None, + }, +} diff --git a/src/newsreader/news/collection/tests/reddit/stream/tests.py b/src/newsreader/news/collection/tests/reddit/stream/tests.py new file mode 100644 index 0000000..19aff61 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/stream/tests.py @@ -0,0 +1,144 @@ +from json.decoder import JSONDecodeError +from unittest.mock import patch +from uuid import uuid4 + +from django.test import TestCase + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.exceptions import ( + StreamDeniedException, + StreamException, + StreamForbiddenException, + StreamNotFoundException, + StreamParseException, + StreamTimeOutException, +) +from newsreader.news.collection.reddit import RedditStream +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.reddit.stream.mocks import simple_mock + + +class RedditStreamTestCase(TestCase): + def setUp(self): + self.maxDiff = None + + self.patched_fetch = patch("newsreader.news.collection.reddit.fetch") + self.mocked_fetch = self.patched_fetch.start() + + def tearDown(self): + patch.stopall() + + def test_simple_stream(self): + self.mocked_fetch.return_value.json.return_value = simple_mock + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + data, stream = stream.read() + + self.assertEquals(data, simple_mock) + self.assertEquals(stream, stream) + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_exception(self): + self.mocked_fetch.side_effect = StreamException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_denied_exception(self): + self.mocked_fetch.side_effect = StreamDeniedException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamDeniedException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_not_found_exception(self): + self.mocked_fetch.side_effect = StreamNotFoundException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamNotFoundException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_time_out_exception(self): + self.mocked_fetch.side_effect = StreamTimeOutException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamTimeOutException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_forbidden_exception(self): + self.mocked_fetch.side_effect = StreamForbiddenException + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamForbiddenException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) + + def test_stream_raises_parse_exception(self): + self.mocked_fetch.return_value.json.side_effect = JSONDecodeError( + "No json found", "{}", 5 + ) + + access_token = str(uuid4()) + user = UserFactory(reddit_access_token=access_token) + + subreddit = SubredditFactory(user=user) + stream = RedditStream(subreddit) + + with self.assertRaises(StreamParseException): + stream.read() + + self.mocked_fetch.assert_called_once_with( + subreddit.url, headers={"Authorization": f"bearer {access_token}"} + ) diff --git a/src/newsreader/news/collection/tests/reddit/test_scheduler.py b/src/newsreader/news/collection/tests/reddit/test_scheduler.py new file mode 100644 index 0000000..cd062b6 --- /dev/null +++ b/src/newsreader/news/collection/tests/reddit/test_scheduler.py @@ -0,0 +1,142 @@ +from datetime import timedelta + +from django.test import TestCase +from django.utils import timezone + +from freezegun import freeze_time + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.reddit import RedditScheduler +from newsreader.news.collection.tests.factories import CollectionRuleFactory + + +@freeze_time("2019-10-30 12:30:00") +class RedditSchedulerTestCase(TestCase): + def test_simple(self): + user_1 = UserFactory( + reddit_access_token="1231414", reddit_refresh_token="5235262" + ) + user_2 = UserFactory( + reddit_access_token="3414777", reddit_refresh_token="3423425" + ) + + user_1_rules = [ + CollectionRuleFactory( + user=user_1, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=4), + enabled=True, + ), + CollectionRuleFactory( + user=user_1, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=3), + enabled=True, + ), + CollectionRuleFactory( + user=user_1, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=2), + enabled=True, + ), + ] + + user_2_rules = [ + CollectionRuleFactory( + user=user_2, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=4), + enabled=True, + ), + CollectionRuleFactory( + user=user_2, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=3), + enabled=True, + ), + CollectionRuleFactory( + user=user_2, + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(days=2), + enabled=True, + ), + ] + + scheduler = RedditScheduler() + scheduled_subreddits = scheduler.get_scheduled_rules() + + user_1_batch = [subreddit.pk for subreddit in scheduled_subreddits[0]] + + self.assertIn(user_1_rules[0].pk, user_1_batch) + self.assertIn(user_1_rules[1].pk, user_1_batch) + self.assertIn(user_1_rules[2].pk, user_1_batch) + + user_2_batch = [subreddit.pk for subreddit in scheduled_subreddits[1]] + + self.assertIn(user_2_rules[0].pk, user_2_batch) + self.assertIn(user_2_rules[1].pk, user_2_batch) + self.assertIn(user_2_rules[2].pk, user_2_batch) + + def test_max_amount(self): + users = UserFactory.create_batch( + reddit_access_token="1231414", reddit_refresh_token="5235262", size=5 + ) + + nested_rules = [ + CollectionRuleFactory.create_batch( + name=f"rule-{index}", + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(seconds=index), + enabled=True, + user=user, + size=15, + ) + for index, user in enumerate(users) + ] + + rules = [rule for rule_list in nested_rules for rule in rule_list] + + scheduler = RedditScheduler() + scheduled_subreddits = [ + subreddit.pk + for batch in scheduler.get_scheduled_rules() + for subreddit in batch + ] + + for rule in rules[16:76]: + with self.subTest(rule=rule): + self.assertIn(rule.pk, scheduled_subreddits) + + for rule in rules[0:15]: + with self.subTest(rule=rule): + self.assertNotIn(rule.pk, scheduled_subreddits) + + def test_max_user_amount(self): + user = UserFactory( + reddit_access_token="1231414", reddit_refresh_token="5235262" + ) + + rules = [ + CollectionRuleFactory( + name=f"rule-{index}", + type=RuleTypeChoices.subreddit, + last_suceeded=timezone.now() - timedelta(seconds=index), + enabled=True, + user=user, + ) + for index in range(1, 17) + ] + + scheduler = RedditScheduler() + scheduled_subreddits = [ + subreddit.pk + for batch in scheduler.get_scheduled_rules() + for subreddit in batch + ] + + for rule in rules[1:16]: + with self.subTest(rule=rule): + self.assertIn(rule.pk, scheduled_subreddits) + + self.assertNotIn(rules[0].pk, scheduled_subreddits) diff --git a/src/newsreader/news/collection/tests/utils/tests.py b/src/newsreader/news/collection/tests/utils/tests.py index 95c5dd2..10013c3 100644 --- a/src/newsreader/news/collection/tests/utils/tests.py +++ b/src/newsreader/news/collection/tests/utils/tests.py @@ -6,97 +6,118 @@ from requests.exceptions import ConnectionError as RequestConnectionError from requests.exceptions import HTTPError, RequestException, SSLError, TooManyRedirects from newsreader.news.collection.exceptions import ( - StreamConnectionError, + StreamConnectionException, StreamDeniedException, StreamException, StreamForbiddenException, StreamNotFoundException, StreamTimeOutException, + StreamTooManyException, ) -from newsreader.news.collection.utils import fetch +from newsreader.news.collection.utils import fetch, post -class FetchTestCase(TestCase): - def setUp(self): - self.patched_get = patch("newsreader.news.collection.utils.requests.get") - self.mocked_get = self.patched_get.start() - +class HelperFunctionTestCase: def test_simple(self): - self.mocked_get.return_value = MagicMock(status_code=200, content="content") + self.mocked_method.return_value = MagicMock(status_code=200, content="content") url = "https://www.bbc.co.uk/news" - response = fetch(url) + response = self.method(url) self.assertEquals(response.content, "content") def test_raises_not_found(self): - self.mocked_get.return_value = MagicMock(status_code=404) + self.mocked_method.return_value = MagicMock(status_code=404) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamNotFoundException): - fetch(url) + self.method(url) def test_raises_denied(self): - self.mocked_get.return_value = MagicMock(status_code=401) + self.mocked_method.return_value = MagicMock(status_code=401) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamDeniedException): - fetch(url) + self.method(url) def test_raises_forbidden(self): - self.mocked_get.return_value = MagicMock(status_code=403) + self.mocked_method.return_value = MagicMock(status_code=403) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamForbiddenException): - fetch(url) + self.method(url) def test_raises_timed_out(self): - self.mocked_get.return_value = MagicMock(status_code=408) + self.mocked_method.return_value = MagicMock(status_code=408) url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamTimeOutException): - fetch(url) + self.method(url) def test_raises_stream_error_on_ssl_error(self): - self.mocked_get.side_effect = SSLError + self.mocked_method.side_effect = SSLError url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) def test_raises_stream_error_on_connection_error(self): - self.mocked_get.side_effect = RequestConnectionError + self.mocked_method.side_effect = RequestConnectionError url = "https://www.bbc.co.uk/news" - with self.assertRaises(StreamConnectionError): - fetch(url) + with self.assertRaises(StreamConnectionException): + self.method(url) def test_raises_stream_error_on_http_error(self): - self.mocked_get.side_effect = HTTPError + self.mocked_method.side_effect = HTTPError url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) def test_raises_stream_error_on_request_exception(self): - self.mocked_get.side_effect = RequestException + self.mocked_method.side_effect = RequestException url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) def test_raises_stream_error_on_too_many_redirects(self): - self.mocked_get.side_effect = TooManyRedirects + self.mocked_method.side_effect = TooManyRedirects url = "https://www.bbc.co.uk/news" with self.assertRaises(StreamException): - fetch(url) + self.method(url) + + def test_raises_stream_error_on_too_many_requests(self): + self.mocked_method.return_value = MagicMock(status_code=429) + + url = "https://www.bbc.co.uk/news" + + with self.assertRaises(StreamTooManyException): + self.method(url) + + +class FetchTestCase(HelperFunctionTestCase, TestCase): + def setUp(self): + self.patch = patch("newsreader.news.collection.utils.requests.get") + self.mocked_method = self.patch.start() + + self.method = fetch + + +class PostTestCase(HelperFunctionTestCase, TestCase): + def setUp(self): + self.patch = patch("newsreader.news.collection.utils.requests.post") + self.mocked_method = self.patch.start() + + self.method = post diff --git a/src/newsreader/news/collection/tests/views/__init__.py b/src/newsreader/news/collection/tests/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/newsreader/news/collection/tests/views/base.py b/src/newsreader/news/collection/tests/views/base.py new file mode 100644 index 0000000..d7de171 --- /dev/null +++ b/src/newsreader/news/collection/tests/views/base.py @@ -0,0 +1,69 @@ +from django.urls import reverse + +from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.core.tests.factories import CategoryFactory + + +class CollectionRuleViewTestCase: + def setUp(self): + self.user = UserFactory(password="test") + self.client.force_login(self.user) + + self.category = CategoryFactory(user=self.user) + self.form_data = {"name": "", "category": "", "url": "", "timezone": ""} + + def test_simple(self): + response = self.client.get(self.url) + + self.assertEquals(response.status_code, 200) + + def test_no_category(self): + self.form_data.update(category="") + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get() + + self.assertEquals(rule.category, None) + + def test_categories_only_from_user(self): + other_user = UserFactory() + other_categories = CategoryFactory.create_batch(size=4, user=other_user) + + response = self.client.get(self.url) + + for category in other_categories: + self.assertNotContains(response, category.name) + + def test_category_of_other_user(self): + other_user = UserFactory() + other_rule = CollectionRuleFactory(name="other rule", user=other_user) + + self.form_data.update( + name="new name", + category=other_rule.category, + url=other_rule.url, + timezone=other_rule.timezone, + ) + + other_url = reverse("news:collection:rule-update", args=[other_rule.pk]) + response = self.client.post(other_url, self.form_data) + + self.assertEquals(response.status_code, 404) + + other_rule.refresh_from_db() + + self.assertEquals(other_rule.name, "other rule") + + def test_with_other_user_rules(self): + other_user = UserFactory() + other_categories = CategoryFactory.create_batch(size=4, user=other_user) + + self.form_data.update(category=other_categories[2].pk) + + response = self.client.post(self.url, self.form_data) + + self.assertContains(response, "not one of the available choices") diff --git a/src/newsreader/news/collection/tests/views/test_bulk_views.py b/src/newsreader/news/collection/tests/views/test_bulk_views.py index 39817c2..5112feb 100644 --- a/src/newsreader/news/collection/tests/views/test_bulk_views.py +++ b/src/newsreader/news/collection/tests/views/test_bulk_views.py @@ -4,7 +4,7 @@ from django.utils.translation import gettext_lazy as _ from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory class CollectionRuleBulkViewTestCase: @@ -21,9 +21,7 @@ class CollectionRuleBulkEnableViewTestCase(CollectionRuleBulkViewTestCase, TestC self.url = reverse("news:collection:rules-enable") - self.rules = CollectionRuleFactory.create_batch( - size=5, user=self.user, enabled=False - ) + self.rules = FeedFactory.create_batch(size=5, user=self.user, enabled=False) def test_simple(self): response = self.client.post( @@ -55,9 +53,7 @@ class CollectionRuleBulkEnableViewTestCase(CollectionRuleBulkViewTestCase, TestC def test_rule_from_other_user(self): other_user = UserFactory() - other_rules = CollectionRuleFactory.create_batch( - size=5, user=other_user, enabled=False - ) + other_rules = FeedFactory.create_batch(size=5, user=other_user, enabled=False) response = self.client.post( self.url, @@ -100,9 +96,7 @@ class CollectionRuleBulkDisableViewTestCase(CollectionRuleBulkViewTestCase, Test self.url = reverse("news:collection:rules-disable") - self.rules = CollectionRuleFactory.create_batch( - size=5, user=self.user, enabled=True - ) + self.rules = FeedFactory.create_batch(size=5, user=self.user, enabled=True) def test_simple(self): response = self.client.post( @@ -134,9 +128,7 @@ class CollectionRuleBulkDisableViewTestCase(CollectionRuleBulkViewTestCase, Test def test_rule_from_other_user(self): other_user = UserFactory() - other_rules = CollectionRuleFactory.create_batch( - size=5, user=other_user, enabled=True - ) + other_rules = FeedFactory.create_batch(size=5, user=other_user, enabled=True) response = self.client.post( self.url, @@ -179,7 +171,7 @@ class CollectionRuleBulkDeleteViewTestCase(CollectionRuleBulkViewTestCase, TestC self.url = reverse("news:collection:rules-delete") - self.rules = CollectionRuleFactory.create_batch(size=5, user=self.user) + self.rules = FeedFactory.create_batch(size=5, user=self.user) def test_simple(self): response = self.client.post( @@ -207,9 +199,7 @@ class CollectionRuleBulkDeleteViewTestCase(CollectionRuleBulkViewTestCase, TestC def test_rule_from_other_user(self): other_user = UserFactory() - other_rules = CollectionRuleFactory.create_batch( - size=5, user=other_user, enabled=True - ) + other_rules = FeedFactory.create_batch(size=5, user=other_user, enabled=True) response = self.client.post( self.url, diff --git a/src/newsreader/news/collection/tests/views/test_crud.py b/src/newsreader/news/collection/tests/views/test_crud.py index a581f0c..61f6835 100644 --- a/src/newsreader/news/collection/tests/views/test_crud.py +++ b/src/newsreader/news/collection/tests/views/test_crud.py @@ -3,80 +3,18 @@ from django.urls import reverse import pytz -from newsreader.accounts.tests.factories import UserFactory +from newsreader.news.collection.choices import RuleTypeChoices from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory +from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase from newsreader.news.core.tests.factories import CategoryFactory -class CollectionRuleViewTestCase: - def setUp(self): - self.user = UserFactory(password="test") - self.client.force_login(self.user) - - self.category = CategoryFactory(user=self.user) - self.form_data = {"name": "", "category": "", "url": "", "timezone": ""} - - def test_simple(self): - response = self.client.get(self.url) - - self.assertEquals(response.status_code, 200) - - def test_no_category(self): - self.form_data.update(category="") - - response = self.client.post(self.url, self.form_data) - self.assertEquals(response.status_code, 302) - - rule = CollectionRule.objects.get() - - self.assertEquals(rule.category, None) - - def test_categories_only_from_user(self): - other_user = UserFactory() - other_categories = CategoryFactory.create_batch(size=4, user=other_user) - - response = self.client.get(self.url) - - for category in other_categories: - self.assertNotContains(response, category.name) - - def test_category_of_other_user(self): - other_user = UserFactory() - other_rule = CollectionRuleFactory(name="other rule", user=other_user) - - self.form_data.update( - name="new name", - category=other_rule.category, - url=other_rule.url, - timezone=other_rule.timezone, - ) - - other_url = reverse("rule-update", args=[other_rule.pk]) - response = self.client.post(other_url, self.form_data) - - self.assertEquals(response.status_code, 404) - - other_rule.refresh_from_db() - - self.assertEquals(other_rule.name, "other rule") - - def test_with_other_user_rules(self): - other_user = UserFactory() - other_categories = CategoryFactory.create_batch(size=4, user=other_user) - - self.form_data.update(category=other_categories[2].pk) - - response = self.client.post(self.url, self.form_data) - - self.assertContains(response, "not one of the available choices") - - class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): super().setUp() - self.url = reverse("rule-create") + self.url = reverse("news:collection:rule-create") self.form_data.update( name="new rule", @@ -92,6 +30,7 @@ class CollectionRuleCreateViewTestCase(CollectionRuleViewTestCase, TestCase): rule = CollectionRule.objects.get(name="new rule") + self.assertEquals(rule.type, RuleTypeChoices.feed) self.assertEquals(rule.url, "https://www.rss.com/rss") self.assertEquals(rule.timezone, str(pytz.utc)) self.assertEquals(rule.favicon, None) @@ -103,10 +42,10 @@ class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): def setUp(self): super().setUp() - self.rule = CollectionRuleFactory( + self.rule = FeedFactory( name="collection rule", user=self.user, category=self.category ) - self.url = reverse("rule-update", args=[self.rule.pk]) + self.url = reverse("news:collection:rule-update", kwargs={"pk": self.rule.pk}) self.form_data.update( name=self.rule.name, @@ -146,3 +85,17 @@ class CollectionRuleUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): self.rule.refresh_from_db() self.assertEquals(self.rule.category, None) + + def test_rules_only(self): + rule = FeedFactory( + name="Python", + url="https://reddit.com/r/python", + user=self.user, + category=self.category, + type=RuleTypeChoices.subreddit, + ) + url = reverse("news:collection:rule-update", kwargs={"pk": rule.pk}) + + response = self.client.get(url) + + self.assertEquals(response.status_code, 404) diff --git a/src/newsreader/news/collection/tests/views/test_import_view.py b/src/newsreader/news/collection/tests/views/test_import_view.py index 776e4c6..f4188e7 100644 --- a/src/newsreader/news/collection/tests/views/test_import_view.py +++ b/src/newsreader/news/collection/tests/views/test_import_view.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from newsreader.accounts.tests.factories import UserFactory from newsreader.news.collection.models import CollectionRule -from newsreader.news.collection.tests.factories import CollectionRuleFactory +from newsreader.news.collection.tests.factories import FeedFactory class OPMLImportTestCase(TestCase): @@ -16,7 +16,7 @@ class OPMLImportTestCase(TestCase): self.client.force_login(self.user) self.form_data = {"file": "", "skip_existing": False} - self.url = reverse("import") + self.url = reverse("news:collection:import") def _get_file_path(self, name): file_dir = os.path.join(settings.DJANGO_PROJECT_DIR, "utils", "tests", "files") @@ -30,22 +30,16 @@ class OPMLImportTestCase(TestCase): response = self.client.post(self.url, self.form_data) - self.assertRedirects(response, reverse("rules")) + self.assertRedirects(response, reverse("news:collection:rules")) rules = CollectionRule.objects.all() self.assertEquals(len(rules), 4) def test_existing_rules(self): - CollectionRuleFactory( - url="http://www.engadget.com/rss-full.xml", user=self.user - ) - CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) - CollectionRuleFactory( - url="http://feeds.feedburner.com/Techcrunch", user=self.user - ) - CollectionRuleFactory( - url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user - ) + FeedFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + FeedFactory(url="https://news.ycombinator.com/rss", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/Techcrunch", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user) file_path = self._get_file_path("feeds.opml") @@ -54,22 +48,16 @@ class OPMLImportTestCase(TestCase): response = self.client.post(self.url, self.form_data) - self.assertRedirects(response, reverse("rules")) + self.assertRedirects(response, reverse("news:collection:rules")) rules = CollectionRule.objects.all() self.assertEquals(len(rules), 8) def test_skip_existing_rules(self): - CollectionRuleFactory( - url="http://www.engadget.com/rss-full.xml", user=self.user - ) - CollectionRuleFactory(url="https://news.ycombinator.com/rss", user=self.user) - CollectionRuleFactory( - url="http://feeds.feedburner.com/Techcrunch", user=self.user - ) - CollectionRuleFactory( - url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user - ) + FeedFactory(url="http://www.engadget.com/rss-full.xml", user=self.user) + FeedFactory(url="https://news.ycombinator.com/rss", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/Techcrunch", user=self.user) + FeedFactory(url="http://feeds.feedburner.com/tweakers/nieuws", user=self.user) file_path = self._get_file_path("feeds.opml") @@ -136,7 +124,7 @@ class OPMLImportTestCase(TestCase): response = self.client.post(self.url, self.form_data) - self.assertRedirects(response, reverse("rules")) + self.assertRedirects(response, reverse("news:collection:rules")) rules = CollectionRule.objects.all() self.assertEquals(len(rules), 2) diff --git a/src/newsreader/news/collection/tests/views/test_subreddit_views.py b/src/newsreader/news/collection/tests/views/test_subreddit_views.py new file mode 100644 index 0000000..a8de55e --- /dev/null +++ b/src/newsreader/news/collection/tests/views/test_subreddit_views.py @@ -0,0 +1,113 @@ +from django.test import TestCase +from django.urls import reverse + +import pytz + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.models import CollectionRule +from newsreader.news.collection.reddit import REDDIT_URL +from newsreader.news.collection.tests.factories import SubredditFactory +from newsreader.news.collection.tests.views.base import CollectionRuleViewTestCase +from newsreader.news.core.tests.factories import CategoryFactory + + +class SubRedditCreateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.form_data = { + "name": "new rule", + "url": "https://www.reddit.com/r/aww", + "category": str(self.category.pk), + } + + self.url = reverse("news:collection:subreddit-create") + + def test_creation(self): + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get(name="new rule") + + self.assertEquals(rule.type, RuleTypeChoices.subreddit) + self.assertEquals(rule.url, "https://www.reddit.com/r/aww.json") + self.assertEquals(rule.timezone, str(pytz.utc)) + self.assertEquals(rule.favicon, None) + self.assertEquals(rule.category.pk, self.category.pk) + self.assertEquals(rule.user.pk, self.user.pk) + + +class SubRedditUpdateViewTestCase(CollectionRuleViewTestCase, TestCase): + def setUp(self): + super().setUp() + + self.rule = SubredditFactory( + name="Python", + url=f"{REDDIT_URL}/r/python.json", + user=self.user, + category=self.category, + type=RuleTypeChoices.subreddit, + ) + self.url = reverse( + "news:collection:subreddit-update", kwargs={"pk": self.rule.pk} + ) + + self.form_data = { + "name": self.rule.name, + "url": self.rule.url, + "category": str(self.category.pk), + "timezone": pytz.utc, + } + + def test_name_change(self): + self.form_data.update(name="Python 2") + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.name, "Python 2") + + def test_category_change(self): + new_category = CategoryFactory(user=self.user) + + self.form_data.update(category=new_category.pk) + + response = self.client.post(self.url, self.form_data) + self.assertEquals(response.status_code, 302) + + self.rule.refresh_from_db() + + self.assertEquals(self.rule.category.pk, new_category.pk) + + def test_subreddit_rules_only(self): + rule = SubredditFactory( + name="Fake subreddit", + url="https://leddit.com/r/python", + user=self.user, + category=self.category, + type=RuleTypeChoices.feed, + ) + url = reverse("news:collection:subreddit-update", kwargs={"pk": rule.pk}) + + response = self.client.get(url) + + self.assertEquals(response.status_code, 404) + + def test_url_change(self): + self.form_data.update(name="aww", url=f"{REDDIT_URL}/r/aww") + + response = self.client.post(self.url, self.form_data) + + self.assertEquals(response.status_code, 302) + + rule = CollectionRule.objects.get(name="aww") + + self.assertEquals(rule.type, RuleTypeChoices.subreddit) + self.assertEquals(rule.url, f"{REDDIT_URL}/r/aww.json") + self.assertEquals(rule.timezone, str(pytz.utc)) + self.assertEquals(rule.favicon, None) + self.assertEquals(rule.category.pk, self.category.pk) + self.assertEquals(rule.user.pk, self.user.pk) diff --git a/src/newsreader/news/collection/urls.py b/src/newsreader/news/collection/urls.py index 1ea17d6..5253210 100644 --- a/src/newsreader/news/collection/urls.py +++ b/src/newsreader/news/collection/urls.py @@ -15,6 +15,8 @@ from newsreader.news.collection.views import ( CollectionRuleListView, CollectionRuleUpdateView, OPMLImportView, + SubRedditCreateView, + SubRedditUpdateView, ) @@ -52,5 +54,15 @@ urlpatterns = [ login_required(CollectionRuleBulkDisableView.as_view()), name="rules-disable", ), + path( + "rules/subreddits/create/", + login_required(SubRedditCreateView.as_view()), + name="subreddit-create", + ), + path( + "rules/subreddits//", + login_required(SubRedditUpdateView.as_view()), + name="subreddit-update", + ), path("rules/import/", login_required(OPMLImportView.as_view()), name="import"), ] diff --git a/src/newsreader/news/collection/utils.py b/src/newsreader/news/collection/utils.py index 9a2e456..8ba6fec 100644 --- a/src/newsreader/news/collection/utils.py +++ b/src/newsreader/news/collection/utils.py @@ -1,5 +1,7 @@ from datetime import datetime +from django.db.models.fields import CharField, TextField +from django.template.defaultfilters import truncatechars from django.utils import timezone import pytz @@ -10,6 +12,9 @@ from requests.exceptions import RequestException from newsreader.news.collection.response_handler import ResponseHandler +DEFAULT_HEADERS = {"User-Agent": "linux:rss.fudiggity.nl:v0.2"} + + def build_publication_date(dt, tz): try: naive_datetime = datetime(*dt[:6]) @@ -20,12 +25,46 @@ def build_publication_date(dt, tz): return published_parsed.astimezone(pytz.utc) -def fetch(url): +def fetch(url, headers={}): + headers = {**DEFAULT_HEADERS, **headers} + with ResponseHandler() as response_handler: try: - response = requests.get(url) + response = requests.get(url, headers=headers) response_handler.handle_response(response) except RequestException as exception: - response_handler.handle_exception(exception) + response_handler.map_exception(exception) return response + + +def post(url, data=None, auth=None, headers={}): + headers = {**DEFAULT_HEADERS, **headers} + + with ResponseHandler() as response_handler: + try: + response = requests.post(url, data=data, auth=auth, headers=headers) + response_handler.handle_response(response) + except RequestException as exception: + response_handler.map_exception(exception) + + return response + + +def truncate_text(cls, field_name, value): + field = cls._meta.get_field(field_name) + max_length = field.max_length + field_cls = type(field) + + is_charfield = bool(issubclass(field_cls, CharField)) + is_textfield = bool(issubclass(field_cls, TextField)) + + if not value or not max_length: + return value + elif not is_charfield or is_textfield: + return value + + if len(value) > max_length: + return truncatechars(value, max_length) + + return value diff --git a/src/newsreader/news/collection/views/__init__.py b/src/newsreader/news/collection/views/__init__.py new file mode 100644 index 0000000..20769f3 --- /dev/null +++ b/src/newsreader/news/collection/views/__init__.py @@ -0,0 +1,13 @@ +from newsreader.news.collection.views.reddit import ( + SubRedditCreateView, + SubRedditUpdateView, +) +from newsreader.news.collection.views.rules import ( + CollectionRuleBulkDeleteView, + CollectionRuleBulkDisableView, + CollectionRuleBulkEnableView, + CollectionRuleCreateView, + CollectionRuleListView, + CollectionRuleUpdateView, + OPMLImportView, +) diff --git a/src/newsreader/news/collection/views/base.py b/src/newsreader/news/collection/views/base.py new file mode 100644 index 0000000..e7f7b63 --- /dev/null +++ b/src/newsreader/news/collection/views/base.py @@ -0,0 +1,36 @@ +from django.urls import reverse_lazy + +import pytz + +from newsreader.news.collection.forms import CollectionRuleForm +from newsreader.news.collection.models import CollectionRule +from newsreader.news.core.models import Category + + +class CollectionRuleViewMixin: + queryset = CollectionRule.objects.order_by("name") + + def get_queryset(self): + user = self.request.user + return self.queryset.filter(user=user) + + +class CollectionRuleDetailMixin: + success_url = reverse_lazy("news:collection:rules") + form_class = CollectionRuleForm + + def get_context_data(self, **kwargs): + context_data = super().get_context_data(**kwargs) + + categories = Category.objects.filter(user=self.request.user).order_by("name") + timezones = [timezone for timezone in pytz.all_timezones] + + context_data["categories"] = categories + context_data["timezones"] = timezones + + return context_data + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs diff --git a/src/newsreader/news/collection/views/reddit.py b/src/newsreader/news/collection/views/reddit.py new file mode 100644 index 0000000..533513b --- /dev/null +++ b/src/newsreader/news/collection/views/reddit.py @@ -0,0 +1,26 @@ +from django.views.generic.edit import CreateView, UpdateView + +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms import SubRedditRuleForm +from newsreader.news.collection.views.base import ( + CollectionRuleDetailMixin, + CollectionRuleViewMixin, +) + + +class SubRedditCreateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView +): + form_class = SubRedditRuleForm + template_name = "news/collection/views/subreddit-create.html" + + +class SubRedditUpdateView( + CollectionRuleViewMixin, CollectionRuleDetailMixin, UpdateView +): + form_class = SubRedditRuleForm + template_name = "news/collection/views/subreddit-update.html" + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(type=RuleTypeChoices.subreddit) diff --git a/src/newsreader/news/collection/views.py b/src/newsreader/news/collection/views/rules.py similarity index 72% rename from src/newsreader/news/collection/views.py rename to src/newsreader/news/collection/views/rules.py index 6fb88df..e020b67 100644 --- a/src/newsreader/news/collection/views.py +++ b/src/newsreader/news/collection/views/rules.py @@ -1,51 +1,20 @@ from django.contrib import messages from django.shortcuts import redirect -from django.urls import reverse, reverse_lazy -from django.utils.translation import gettext_lazy as _ +from django.urls import reverse +from django.utils.translation import gettext as _ from django.views.generic.edit import CreateView, FormView, UpdateView from django.views.generic.list import ListView -import pytz - -from newsreader.news.collection.forms import ( - CollectionRuleBulkForm, - CollectionRuleForm, - OPMLImportForm, -) +from newsreader.news.collection.choices import RuleTypeChoices +from newsreader.news.collection.forms import CollectionRuleBulkForm, OPMLImportForm from newsreader.news.collection.models import CollectionRule -from newsreader.news.core.models import Category +from newsreader.news.collection.views.base import ( + CollectionRuleDetailMixin, + CollectionRuleViewMixin, +) from newsreader.utils.opml import parse_opml -class CollectionRuleViewMixin: - queryset = CollectionRule.objects.order_by("name") - - def get_queryset(self): - user = self.request.user - return self.queryset.filter(user=user).order_by("name") - - -class CollectionRuleDetailMixin: - success_url = reverse_lazy("news:collection:rules") - form_class = CollectionRuleForm - - def get_context_data(self, **kwargs): - context_data = super().get_context_data(**kwargs) - - rules = Category.objects.filter(user=self.request.user).order_by("name") - timezones = [timezone for timezone in pytz.all_timezones] - - context_data["categories"] = rules - context_data["timezones"] = timezones - - return context_data - - def get_form_kwargs(self): - kwargs = super().get_form_kwargs() - kwargs["user"] = self.request.user - return kwargs - - class CollectionRuleListView(CollectionRuleViewMixin, ListView): paginate_by = 50 template_name = "news/collection/views/rules.html" @@ -58,6 +27,10 @@ class CollectionRuleUpdateView( template_name = "news/collection/views/rule-update.html" context_object_name = "rule" + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(type=RuleTypeChoices.feed) + class CollectionRuleCreateView( CollectionRuleViewMixin, CollectionRuleDetailMixin, CreateView @@ -121,7 +94,6 @@ class CollectionRuleBulkDeleteView(CollectionRuleBulkView): class OPMLImportView(FormView): form_class = OPMLImportForm - success_url = reverse_lazy("news:collection:rules") template_name = "news/collection/views/import.html" def form_valid(self, form): @@ -145,3 +117,6 @@ class OPMLImportView(FormView): messages.success(self.request, message) return super().form_valid(form) + + def get_success_url(self): + return reverse("news:collection:rules") diff --git a/src/newsreader/news/core/migrations/0007_auto_20200706_2312.py b/src/newsreader/news/core/migrations/0007_auto_20200706_2312.py new file mode 100644 index 0000000..751faf9 --- /dev/null +++ b/src/newsreader/news/core/migrations/0007_auto_20200706_2312.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.7 on 2020-07-06 21:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("core", "0006_auto_20200524_1218")] + + operations = [ + migrations.AlterField( + model_name="post", + name="body", + field=models.TextField(blank=True, default=""), + preserve_default=False, + ) + ] diff --git a/src/newsreader/scss/components/section/_text-section.scss b/src/newsreader/scss/components/section/_text-section.scss index 88e3e72..9c5e8fc 100644 --- a/src/newsreader/scss/components/section/_text-section.scss +++ b/src/newsreader/scss/components/section/_text-section.scss @@ -8,4 +8,3 @@ background-color: $white; } - diff --git a/src/newsreader/scss/elements/button/_button.scss b/src/newsreader/scss/elements/button/_button.scss index 2e97d6b..50af49e 100644 --- a/src/newsreader/scss/elements/button/_button.scss +++ b/src/newsreader/scss/elements/button/_button.scss @@ -43,4 +43,13 @@ background-color: lighten($button-blue, 5%); } } + + &--reddit { + color: $white !important; + background-color: lighten($reddit-orange, 5%); + + &:hover { + background-color: $reddit-orange; + } + } } diff --git a/src/newsreader/scss/pages/settings/index.scss b/src/newsreader/scss/pages/settings/index.scss index 28837cd..c52f46b 100644 --- a/src/newsreader/scss/pages/settings/index.scss +++ b/src/newsreader/scss/pages/settings/index.scss @@ -1,11 +1,11 @@ #settings--page { - .settings-form__fieldset:last-child { - & span { - display: flex; - flex-direction: row; - - & >:first-child { - margin: 0 5px; + .form { + &__section { + &--last { + & .fieldset { + gap: 15px; + justify-content: flex-start; + } } } } diff --git a/src/newsreader/scss/partials/_colors.scss b/src/newsreader/scss/partials/_colors.scss index 08c7169..aee33c2 100644 --- a/src/newsreader/scss/partials/_colors.scss +++ b/src/newsreader/scss/partials/_colors.scss @@ -40,3 +40,5 @@ $white: rgba(255, 255, 255, 1); $black: rgba(0, 0, 0, 1); $blue: darken($azureish-white, +50%); $dark: rgba(0, 0, 0, 0.4); + +$reddit-orange: rgba(255, 69, 0, 1);