diff --git a/README.md b/README.md index 39313f87..3a9a498d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ After switching to Wagtail, the documentation has to be updated. Stay tuned 😄 - Video support - not as sophisticated as image / audio support, but it works 🤗 - Comments via [django-fluent-comments](https://github.com/django-fluent/django-fluent-comments) and a build in moderating spam filter -- Full-text search via [django-watson](https://github.com/etianen/django-watson) ## Deployment diff --git a/cast/__init__.py b/cast/__init__.py index 46bb9886..7e4aadc6 100644 --- a/cast/__init__.py +++ b/cast/__init__.py @@ -1,4 +1,4 @@ """ Just another blogging / podcasting package """ -__version__ = "0.2.1" +__version__ = "0.2.2" diff --git a/cast/access_log.py b/cast/access_log.py deleted file mode 100644 index 049e4842..00000000 --- a/cast/access_log.py +++ /dev/null @@ -1,162 +0,0 @@ -from datetime import datetime -from io import StringIO - -import pytz - -from .models import Request - - -# import pandas as pd - - -def get_last_request_position(access_log_path, last_request): - """ - Find the last position of a request with ip and timestamp in - an access logfile. Used to find all requests which are not yet immported. - There could be multiple requests with the same ip and timestamp, therefore - we have to collect all of them (candidates) and then return just the - position of the last one. - """ - if last_request is None: - return 0 - strptime = datetime.strptime - last_ip = last_request.ip - last_timestamp = last_request.timestamp - candidates = [] - with open(access_log_path, "rb") as f: - for position, line in enumerate(f): - try: - line = line.decode("utf8") - except UnicodeDecodeError: - continue - if last_ip in line: - date_str = line.split("[")[1].split("]")[0] - timestamp = strptime(date_str, "%d/%b/%Y:%H:%M:%S %z") - if timestamp == last_timestamp: - candidates.append((position, line)) - if timestamp > last_timestamp: - break - return candidates[-1][0] - - -def parse_str(x): - """ - Returns the string delimited by two characters. - - Example: - `>>> parse_str('[my string]')` - `'my string'` - """ - return x[1:-1] if x is not None else x - - -def access_log_to_buffer(access_log_path, start_position=0, chunk_size=None): - """ - Read all lines from access_log_path starting at start_position and append - them to an empty buffer. Return that buffer. - """ - log_buffer = StringIO() - weird_log_lines = [ - "", - ] - with open(access_log_path, "rb") as f: - line_count = 0 - for position, line in enumerate(f): - try: - line = line.decode("utf8") - except UnicodeDecodeError: - # ignore weird characters (should not happen that often) - continue - should_continue = False - for wll in weird_log_lines: - if wll in line: - # ignore weird js cracking attempts etc - should_continue = True - if should_continue: - continue - if position > start_position: - log_buffer.write(line) - line_count += 1 - if chunk_size is not None and line_count == chunk_size: - # read only chunk_size lines if set - break - log_buffer.seek(0) - return log_buffer - - -def parse_datetime(x): - """ - Parses datetime with timezone formatted as: - `[day/month/year:hour:minute:second zone]` - - Example: - `>>> parse_datetime('13/Nov/2015:11:45:42 +0000')` - `datetime.datetime(2015, 11, 3, 11, 45, 4, tzinfo=)` - - Due to problems parsing the timezone (`%z`) with `datetime.strptime`, the - timezone will be obtained using the `pytz` library. - """ - dt = datetime.strptime(x[1:-7], "%d/%b/%Y:%H:%M:%S") - dt_tz = int(x[-6:-3]) * 60 + int(x[-3:-1]) - return dt.replace(tzinfo=pytz.FixedOffset(dt_tz)) - - -# def get_dataframe_from_position(access_log_path, start_position=0, chunk_size=None): -# log_buffer = access_log_to_buffer( -# access_log_path, start_position=start_position, chunk_size=chunk_size -# ) -# df = pd.read_csv( -# log_buffer, -# sep=r'\s(?=(?:[^"]*"[^"]*")*[^"]*$)(?![^\[]*\])', -# engine="python", -# na_values="-", -# header=None, -# usecols=[0, 3, 4, 5, 6, 7, 8], -# names=[ -# "ip", -# "user", -# "user1", -# "timestamp", -# "request", -# "status", -# "size", -# "referer", -# "user_agent", -# ], -# converters={ -# "timestamp": parse_datetime, -# "request": parse_str, -# "status": int, -# "size": int, -# "referer": parse_str, -# "user_agent": parse_str, -# }, -# ) -# df = df.drop(df[df.ip.str.len() > 100].index) -# try: -# # breaks on empty df -# df[["method", "path", "protocol"]] = df.request.str.split(" ", expand=True) -# except ValueError: -# pass -# df = df.drop("request", axis=1) -# return df - - -def pandas_rows_to_dict(rows): - """ - Takes a row from df.iterrows() and makes it json serializable. - """ - request_method_lookup = {v: k for k, v in Request.REQUEST_METHOD_CHOICES} - protocol_lookup = {v: k for k, v in Request.HTTP_PROTOCOL_CHOICES} - # dunno why this only works on single row - # values = {"referer": None, "user_agent": None} - # row_dict = row.fillna(value=values).to_dict() - aux = [] - for row_dict in rows: - row_dict["method"] = request_method_lookup[row_dict["method"]] - row_dict["protocol"] = protocol_lookup[row_dict["protocol"]] - row_dict["timestamp"] = row_dict["timestamp"].isoformat() - for attr in ("method", "protocol", "status", "size"): - row_dict[attr] = str(row_dict[attr]) - aux.append(row_dict) - return aux diff --git a/cast/api/serializers.py b/cast/api/serializers.py index 7bb6175d..5674d3c3 100644 --- a/cast/api/serializers.py +++ b/cast/api/serializers.py @@ -2,8 +2,7 @@ from rest_framework import serializers -from ..models import Audio, Request, Video - +from ..models import Audio, Video logger = logging.getLogger(__name__) @@ -31,13 +30,8 @@ class AudioPodloveSerializer(serializers.HyperlinkedModelSerializer): audio = serializers.ListField() chapters = serializers.ListField() duration = serializers.CharField(source="duration_str") + link = serializers.URLField(source="episode_url") class Meta: model = Audio - fields = ("title", "subtitle", "audio", "duration", "chapters") - - -class RequestSerializer(serializers.ModelSerializer): - class Meta: - model = Request - fields = "__all__" + fields = ("title", "subtitle", "audio", "duration", "chapters", "link") diff --git a/cast/api/urls.py b/cast/api/urls.py index 88f4e748..2d8f659a 100644 --- a/cast/api/urls.py +++ b/cast/api/urls.py @@ -25,8 +25,6 @@ views.AudioPodloveDetailView.as_view(), name="audio_podlove_detail", ), - # request - path("request/", views.RequestListView.as_view(), name="request_list"), # comment training data path("comment_training_data/", views.CommentTrainingDataView.as_view(), name="comment-training-data"), ] diff --git a/cast/api/views.py b/cast/api/views.py index 0ff20287..a6d2f6a4 100644 --- a/cast/api/views.py +++ b/cast/api/views.py @@ -5,22 +5,16 @@ from django.http import JsonResponse from django.urls import reverse from django.views.generic import CreateView -from rest_framework import generics, status +from rest_framework import generics from rest_framework.decorators import api_view from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.serializers import ListSerializer from rest_framework.views import APIView from ..forms import VideoForm -from ..models import Audio, Request, SpamFilter, Video -from .serializers import ( - AudioPodloveSerializer, - AudioSerializer, - RequestSerializer, - VideoSerializer, -) +from ..models import Audio, SpamFilter, Video +from .serializers import AudioPodloveSerializer, AudioSerializer, VideoSerializer from .viewmixins import AddRequestUserMixin, FileUploadResponseMixin logger = logging.getLogger(__name__) @@ -37,7 +31,6 @@ def api_root(request): # ("galleries", request.build_absolute_uri(reverse("cast:api:gallery_list"))), ("videos", request.build_absolute_uri(reverse("cast:api:video_list"))), ("audios", request.build_absolute_uri(reverse("cast:api:audio_list"))), - ("requests", request.build_absolute_uri(reverse("cast:api:request_list"))), ("comment_training_data", request.build_absolute_uri(reverse("cast:api:comment-training-data"))), ) return Response(OrderedDict(root_api_urls)) @@ -93,28 +86,16 @@ class AudioPodloveDetailView(generics.RetrieveAPIView): queryset = Audio.objects.all() serializer_class = AudioPodloveSerializer - -class RequestListView(generics.ListCreateAPIView): - queryset = Request.objects.all().order_by("-timestamp") - serializer_class = RequestSerializer - pagination_class = StandardResultsSetPagination - permission_classes = (IsAuthenticated,) - - def create(self, request, *args, **kwargs): - """Allow for bulk create via many=True.""" - serializer = self.get_serializer(data=request.data, many=isinstance(request.data, list)) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - - def perform_create(self, serializer): - """Use bulk_create for request lists, normal model serializer otherwise.""" - if isinstance(serializer, ListSerializer): - requests = [Request(**d) for d in serializer.validated_data] - Request.objects.bulk_create(requests) - else: - serializer.save() + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + if (episode_id := request.query_params.get("episode_id")) is not None: + try: + episode_id = int(episode_id) + instance.set_episode_id(episode_id) + except (ValueError, TypeError): + pass + serializer = self.get_serializer(instance) + return Response(serializer.data) class CommentTrainingDataView(APIView): diff --git a/cast/blocks.py b/cast/blocks.py index ae43a6b9..a902c06d 100644 --- a/cast/blocks.py +++ b/cast/blocks.py @@ -1,8 +1,17 @@ from itertools import chain, islice, tee from django.utils.functional import cached_property - -from wagtail.core.blocks import ChooserBlock, ListBlock +from django.utils.safestring import mark_safe +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import get_lexer_by_name +from wagtail.core.blocks import ( + CharBlock, + ChooserBlock, + ListBlock, + StructBlock, + TextBlock, +) def previous_and_next(iterable): @@ -58,3 +67,16 @@ def widget(self): def get_form_state(self, value): return self.widget.get_value_data(value) + + +class CodeBlock(StructBlock): + language = CharBlock(help_text="The language of the code block") + source = TextBlock(rows=8, help_text="The source code of the block") + + def render_basic(self, value, context=None): + if value: + lexer = get_lexer_by_name(value["language"], stripall=True) + highlighted = highlight(value["source"], lexer, HtmlFormatter()) + return mark_safe(highlighted) + else: + return "" diff --git a/cast/management/commands/access_log_import.py b/cast/management/commands/access_log_import.py deleted file mode 100644 index 122d4c79..00000000 --- a/cast/management/commands/access_log_import.py +++ /dev/null @@ -1,58 +0,0 @@ -import os -import time - -from collections import namedtuple -from pathlib import Path - -from django.core.management.base import BaseCommand - -import dateutil.parser -import requests - -from ...access_log import ( - get_dataframe_from_position, - get_last_request_position, - pandas_rows_to_dict, -) - - -def insert_log_chunk(request_api_url, api_token, access_log_path, chunk_size=1000): - now = time.time() - last_request = None - headers = {"Authorization": f"Token {api_token}"} - result = requests.get(request_api_url, headers=headers) - try: - last_request_data = result.json()["results"][0] - last_request_data["timestamp"] = dateutil.parser.parse(last_request_data["timestamp"]) - last_request = namedtuple("Request", last_request_data.keys())(*last_request_data.values()) - except IndexError: - pass - print("last_request: ", last_request) - last_position = get_last_request_position(access_log_path, last_request) - print("last_position: ", last_position) - df = get_dataframe_from_position(access_log_path, start_position=last_position, chunk_size=chunk_size) - if df.shape[0] == 0: - # no more lines - return None, True - print("get df: ", time.time() - now) - raw_rows = df.iloc[:chunk_size].fillna("").to_dict(orient="rows") - rows = pandas_rows_to_dict(raw_rows) - print("transform rows: ", time.time() - now) - result = requests.post(request_api_url, json=rows, headers=headers) - print("total chunk: ", time.time() - now) - return result, False - - -class Command(BaseCommand): - help = "Import requests from an access.log file." - - def handle(self, *args, **options): - request_api_url = os.environ.get("REQUEST_API_URL") - access_log_path = Path(os.environ.get("ACCESS_LOG_PATH")) - api_token = os.environ.get("API_TOKEN") - print(request_api_url, access_log_path, api_token) - now = time.time() - done = False - while not done: - result, done = insert_log_chunk(request_api_url, api_token, access_log_path, chunk_size=20000) - print("total: ", time.time() - now) diff --git a/cast/models/__init__.py b/cast/models/__init__.py index b8913d7b..6e0f453a 100644 --- a/cast/models/__init__.py +++ b/cast/models/__init__.py @@ -4,7 +4,6 @@ from .itunes import ItunesArtWork from .moderation import SpamFilter from .pages import Blog, HomePage, Post, sync_media_ids -from .request import Request from .video import Video, get_video_dimensions __all__ = [ @@ -19,7 +18,6 @@ ItunesArtWork, Post, sync_media_ids, - Request, SpamFilter, Video, get_video_dimensions, diff --git a/cast/models/audio.py b/cast/models/audio.py index 7c308800..125ac6f8 100644 --- a/cast/models/audio.py +++ b/cast/models/audio.py @@ -2,7 +2,6 @@ import logging import re import subprocess - from datetime import timedelta from pathlib import Path @@ -10,15 +9,12 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ - +from model_utils.models import TimeStampedModel +from taggit.managers import TaggableManager from wagtail.core.models import CollectionMember from wagtail.search import index from wagtail.search.queryset import SearchableQuerySetMixin -from model_utils.models import TimeStampedModel -from taggit.managers import TaggableManager - - logger = logging.getLogger(__name__) @@ -189,6 +185,30 @@ def get_chaptermark_data_from_file(self, audio_format): ffprobe_data = json.loads(subprocess.run(command, check=True, stdout=subprocess.PIPE).stdout) return self.clean_ffprobe_chaptermarks(ffprobe_data) + def set_episode_id(self, episode_id): + """Set the episode id for this audio file to be able to return audio.episode_url in api.""" + self._episode_id = episode_id + + def get_episode(self, episode_id=None): + episodes = self.posts.all() + if episode_id is not None: + episodes = episodes.filter(pk=episode_id) + episodes = list(episodes) + if len(episodes) == 1: + return episodes[0] + return None + + @property + def episode_url(self): + """Return the url to the episode this audio file belongs to.""" + episode_id = None + if hasattr(self, "_episode_id"): + episode_id = self._episode_id + episode = self.get_episode(episode_id) + if episode is not None: + return episode.full_url + return None + @property def podlove_url(self): return reverse("cast:api:audio_podlove_detail", kwargs={"pk": self.pk}) diff --git a/cast/models/pages.py b/cast/models/pages.py index 4753e46e..8a61a7cf 100644 --- a/cast/models/pages.py +++ b/cast/models/pages.py @@ -20,7 +20,7 @@ from wagtail.search import index from cast import appsettings -from cast.blocks import AudioChooserBlock, GalleryBlock, VideoChooserBlock +from cast.blocks import AudioChooserBlock, CodeBlock, GalleryBlock, VideoChooserBlock from cast.filters import PostFilterset from cast.models import get_or_create_gallery from cast.models.itunes import ItunesArtWork @@ -180,6 +180,7 @@ def get_context(self, request, *args, **kwargs): class ContentBlock(blocks.StreamBlock): heading = blocks.CharBlock(classname="full title") paragraph = blocks.RichTextBlock() + code = CodeBlock(icon="code") image = ImageChooserBlock(template="cast/image/image.html") gallery = GalleryBlock(ImageChooserBlock()) embed = EmbedBlock() diff --git a/cast/models/request.py b/cast/models/request.py deleted file mode 100644 index 3746cf8c..00000000 --- a/cast/models/request.py +++ /dev/null @@ -1,35 +0,0 @@ -from django.db import models - - -class Request(models.Model): - """ - Hold requests from access.log files. - """ - - ip = models.GenericIPAddressField() - timestamp = models.DateTimeField() - status = models.PositiveSmallIntegerField() - size = models.PositiveIntegerField() - referer = models.CharField(max_length=2048, blank=True, null=True) - user_agent = models.CharField(max_length=1024, blank=True, null=True) - - REQUEST_METHOD_CHOICES = [ - (1, "GET"), - (2, "HEAD"), - (3, "POST"), - (4, "PUT"), - (5, "PATCH"), - (6, "DELETE"), - (7, "OPTIONS"), - (8, "CONNECT"), - (8, "TRACE"), - ] - method = models.PositiveSmallIntegerField(choices=REQUEST_METHOD_CHOICES) - - path = models.CharField(max_length=1024) - - HTTP_PROTOCOL_CHOICES = [(1, "HTTP/1.0"), (2, "HTTP/1.1"), (3, "HTTP/2.0")] - protocol = models.PositiveSmallIntegerField(choices=HTTP_PROTOCOL_CHOICES) - - def __str__(self): - return f"{self.pk} {self.ip} {self.path}" diff --git a/cast/urls.py b/cast/urls.py index bc9a0436..f57ba095 100644 --- a/cast/urls.py +++ b/cast/urls.py @@ -2,15 +2,12 @@ from django.views.decorators.cache import cache_page from . import feeds -from .views.dashboard import DashboardView app_name = "cast" urlpatterns = [ # API # url(r"^api/", include("cast.api.urls", namespace="api")), path("api/", include("cast.api.urls", namespace="api")), - # Dashboard - path("dashboard/", view=DashboardView.as_view(), name="dashboard"), # Feeds path( "/feed/rss.xml", diff --git a/cast/views/dashboard.py b/cast/views/dashboard.py deleted file mode 100644 index 232cc00a..00000000 --- a/cast/views/dashboard.py +++ /dev/null @@ -1,94 +0,0 @@ -import json -import logging - -from datetime import datetime, timedelta # noqa - will get used soon - -from django.contrib.auth.mixins import LoginRequiredMixin -from django.db.models import Count -from django.db.models.functions import TruncWeek -from django.views.generic import TemplateView - -import plotly.graph_objs as go -import pytz # noqa - will get used soon - -from plotly.utils import PlotlyJSONEncoder - -from cast.models import Request - - -logger = logging.getLogger(__name__) - - -class DashboardView(LoginRequiredMixin, TemplateView): - template_name = "cast/dashboard.html" - - def get_day_chart(self): - qs = Request.objects.extra(select={"day": "date(timestamp)"}).values("day").annotate(hits=Count("pk")) - x, y = [], [] - for num, row in enumerate(qs, 1): - # trace["x"].append(num) - x.append(row["day"].strftime("%Y-%m-%d")) - y.append(row["hits"]) - trace = go.Scatter(x=x, y=y, text="Hits", name="Hits per day") - - layout = go.Layout( - title=go.layout.Title(text="Hits per day", xref="paper", x=0), - xaxis=go.layout.XAxis(title=go.layout.xaxis.Title(text="Days")), - yaxis=go.layout.YAxis(title=go.layout.yaxis.Title(text="Hits")), - ) - return trace, layout - - def last_day(self, d, day_name): - days_of_week = [ - "sunday", - "monday", - "tuesday", - "wednesday", - "thursday", - "friday", - "saturday", - ] - target_day = days_of_week.index(day_name.lower()) - delta_day = target_day - d.isoweekday() - if delta_day >= 0: - delta_day -= 7 # go back 7 days - return d + timedelta(days=delta_day) - - def get_week_chart(self): - # last_sunday = self.last_day(pytz.utc.localize(datetime.today()), 'sunday') - qs = ( - Request.objects - # .filter(timestamp__lte=last_sunday) - .annotate(week=TruncWeek("timestamp")) - .values("week") - .annotate(hits=Count("pk")) - ) - x, y = [], [] - for num, row in enumerate(qs, 1): - # trace["x"].append(num) - x.append(row["week"].strftime("%Y-%m-%d")) - y.append(row["hits"]) - trace = go.Scatter(x=x, y=y, text="Hits") - - layout = go.Layout( - title=go.layout.Title(text="Hits per week", xref="paper", x=0), - xaxis=go.layout.XAxis(title=go.layout.xaxis.Title(text="Weeks")), - yaxis=go.layout.YAxis(title=go.layout.yaxis.Title(text="Hits")), - ) - return trace, layout - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - # data/layout for hits per day chart - day_trace, day_layout = self.get_day_chart() - day_data = [day_trace] - context["day_data"] = json.dumps(day_data, cls=PlotlyJSONEncoder) - context["day_layout"] = json.dumps(day_layout, cls=PlotlyJSONEncoder) - - # data/layout for hits per week chart - week_trace, week_layout = self.get_week_chart() - week_data = [week_trace] - context["week_data"] = json.dumps(week_data, cls=PlotlyJSONEncoder) - context["week_layout"] = json.dumps(week_layout, cls=PlotlyJSONEncoder) - return context diff --git a/cast/wagtail_hooks.py b/cast/wagtail_hooks.py index 6f8cd047..73ecb921 100644 --- a/cast/wagtail_hooks.py +++ b/cast/wagtail_hooks.py @@ -1,9 +1,8 @@ from django.urls import include, path, reverse from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ - +from wagtail import hooks from wagtail.admin.menu import MenuItem -from wagtail.core import hooks from wagtail.core.permission_policies.collections import ( CollectionOwnershipPermissionPolicy, ) diff --git a/docs/conf.py b/docs/conf.py index c2ecb6c8..dc5f83d9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -9,7 +9,7 @@ project = "Django Cast" copyright = "2022, Jochen Wersdörfer" author = "Jochen Wersdörfer" -release = "0.2.1" +release = "0.2.2" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration diff --git a/docs/releases/0.2.2.rst b/docs/releases/0.2.2.rst new file mode 100644 index 00000000..7c2bfaba --- /dev/null +++ b/docs/releases/0.2.2.rst @@ -0,0 +1,10 @@ +0.2.2 (2023-01-15) ++++++++++++++++++++ + +Podlove Player share sheet and code blocks for Wagtail. + +* Fixed share sheet of Podlove Player +* Added code blocks for Wagtail +* Refactoring removing some of the @pytest.mark.django_db decorators +* Removed all the request analytics stuff (switched using plausible...) +* Removed django-watson dependency since it is not used anymore diff --git a/docs/releases/index.rst b/docs/releases/index.rst index 016b3271..3a50093e 100644 --- a/docs/releases/index.rst +++ b/docs/releases/index.rst @@ -7,6 +7,8 @@ Versions .. toctree:: :maxdepth: 1 + 0.2.2 + 0.2.1 0.2.0 0.1.35 0.1.34 diff --git a/notebooks/wagtail/code_block.ipynb b/notebooks/wagtail/code_block.ipynb new file mode 100644 index 00000000..609fe0a5 --- /dev/null +++ b/notebooks/wagtail/code_block.ipynb @@ -0,0 +1,153 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 5, + "id": "c4be38ec", + "metadata": {}, + "outputs": [], + "source": [ + "from pygments import highlight\n", + "from pygments.lexers import PythonLexer\n", + "from pygments.formatters import HtmlFormatter" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "d3d8e37d", + "metadata": {}, + "outputs": [], + "source": [ + "page = Page.objects.get(pk=21)\n", + "post = page.specific" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "24fa853a", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "post" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "ba497601", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "
{'language': 'python', 'source': 'def hello_world():\\r\\n print("hello world!")'}
\n" + ] + } + ], + "source": [ + "print(post.body)" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "a9294df5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'type': 'overview', 'value': [{'type': 'code', 'value': {'language': 'python', 'source': 'def hello_world():\\r\\n print(\"hello world!\")'}, 'id': '3d330bcc-0d75-4da3-8737-2791f91e1024'}], 'id': 'a73f27e5-6da2-49eb-94a9-071b6538e42f'}\n" + ] + } + ], + "source": [ + "print(post.body.raw_data[0])" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "844ea89a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{'language': 'python', 'source': 'def hello_world():\\r\\n print(\"hello world!\")'}\n" + ] + } + ], + "source": [ + "code = post.body.raw_data[0][\"value\"][0][\"value\"]\n", + "print(code)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "905d8196", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "
def hello_world():\n",
+      "    print("hello world!")\n",
+      "
\n", + "\n" + ] + } + ], + "source": [ + "print(highlight(code, PythonLexer(), HtmlFormatter()))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7e03653", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Django Shell-Plus", + "language": "python", + "name": "django_extensions" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index c4b54d07..c009474b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,10 +46,10 @@ dependencies = [ "django-model-utils", "django-tag-parser", "django-threadedcomments", - "django-watson", "feedparser", "markdown", "plotly", + "Pygments", "python-akismet", "python-slugify", "wagtail", diff --git a/tests/conftest.py b/tests/conftest.py index 1063755a..027c5668 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,25 +2,20 @@ import json import os import shutil - from copy import deepcopy from datetime import datetime -from pathlib import Path +import pytest +import pytz from django.conf import settings from django.contrib.auth.models import Group from django.core.files.uploadedfile import SimpleUploadedFile from django.test.client import RequestFactory from django.utils import timezone - -from wagtail.core.models import Site -from wagtail.images.models import Image - -import pytest -import pytz - from django_comments import get_model as get_comments_model from rest_framework.test import APIClient +from wagtail.core.models import Site +from wagtail.images.models import Image from cast import appsettings from cast.models import Audio, ChapterMark, File, ItunesArtWork, Video @@ -466,6 +461,19 @@ def podcast_episode(blog, audio, body): ) +@pytest.fixture() +def podcast_episode_with_same_audio(blog, audio, body): + return PostFactory( + owner=blog.owner, + parent=blog, + title="test podcast episode 2", + slug="test-podcast-entry2", + pub_date=timezone.now(), + podcast_audio=audio, + body=body, + ) + + @pytest.fixture() def podcast_episode_with_different_visible_date(blog, audio): visible_date = pytz.timezone("Europe/Berlin").localize(datetime(2019, 1, 1, 8)) @@ -596,18 +604,3 @@ def comment_spam(post): ) instance.save() return instance - - -@pytest.fixture() -def access_log_path(fixture_dir): - return Path(fixture_dir) / "access.log" - - -@pytest.fixture() -def last_request_dummy(): - class RequestDummy: - def __init__(self): - self.timestamp = datetime.strptime("01/Dec/2018:06:55:44 +0100", "%d/%b/%Y:%H:%M:%S %z") - self.ip = "79.230.47.221" - - return RequestDummy() diff --git a/tests/test_access_log.py b/tests/test_access_log.py deleted file mode 100644 index 7392548e..00000000 --- a/tests/test_access_log.py +++ /dev/null @@ -1,12 +0,0 @@ -# from cast.access_log import get_last_request_position -# from cast.access_log import get_dataframe_from_position - - -# class TestParseAccesslog: -# def test_get_access_log_position(self, access_log_path, last_request_dummy): -# position = get_last_request_position(access_log_path, last_request_dummy) -# assert position == 4 -# -# def test_get_df_from_access_log(self, access_log_path): -# df = get_dataframe_from_position(access_log_path) -# assert df.shape == (5, 9) diff --git a/tests/test_api.py b/tests/test_api.py index cdfdea77..46923b5e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -5,10 +5,6 @@ from .factories import UserFactory -# from cast.access_log import pandas_rows_to_dict -# from cast.access_log import get_last_request_position -# from cast.access_log import get_dataframe_from_position - def test_api_root(api_client): """Test that the API root returns a 200.""" @@ -18,12 +14,13 @@ def test_api_root(api_client): class TestBlogVideo: + pytestmark = pytest.mark.django_db + @classmethod def setup_class(cls): cls.list_url = reverse("cast:api:video_list") cls.detail_url = reverse("cast:api:video_detail", kwargs={"pk": 1}) - @pytest.mark.django_db def test_video_list_endpoint_without_authentication(self, api_client): """Check for not authenticated status code if trying to access the list endpoint without being authenticated. @@ -31,7 +28,6 @@ def test_video_list_endpoint_without_authentication(self, api_client): r = api_client.get(self.list_url, format="json") assert r.status_code == 403 - @pytest.mark.django_db def test_video_detail_endpoint_without_authentication(self, api_client): """Check for not authenticated status code if trying to access the detail endpoint without being authenticated. @@ -39,7 +35,6 @@ def test_video_detail_endpoint_without_authentication(self, api_client): r = api_client.get(self.detail_url, format="json") assert r.status_code == 403 - @pytest.mark.django_db def test_video_list_endpoint_with_authentication(self, api_client): """Check for list result when accessing the list endpoint being logged in. @@ -53,12 +48,13 @@ def test_video_list_endpoint_with_authentication(self, api_client): class TestBlogAudio: + pytestmark = pytest.mark.django_db + @classmethod def setup_class(cls): cls.list_url = reverse("cast:api:audio_list") cls.detail_url = reverse("cast:api:audio_detail", kwargs={"pk": 1}) - @pytest.mark.django_db def test_audio_list_endpoint_without_authentication(self, api_client): """Check for not authenticated status code if trying to access the list endpoint without being authenticated. @@ -66,7 +62,6 @@ def test_audio_list_endpoint_without_authentication(self, api_client): r = api_client.get(self.list_url, format="json") assert r.status_code == 403 - @pytest.mark.django_db def test_audio_detail_endpoint_without_authentication(self, api_client): """Check for not authenticated status code if trying to access the detail endpoint without being authenticated. @@ -74,7 +69,6 @@ def test_audio_detail_endpoint_without_authentication(self, api_client): r = api_client.get(self.detail_url, format="json") assert r.status_code == 403 - @pytest.mark.django_db def test_audio_list_endpoint_with_authentication(self, api_client): """Check for list result when accessing the list endpoint being logged in. @@ -88,14 +82,14 @@ def test_audio_list_endpoint_with_authentication(self, api_client): class TestPodcastAudio: - @pytest.mark.django_db + pytestmark = pytest.mark.django_db + def test_podlove_detail_endpoint_without_authentication(self, api_client, audio): """Should be accessible without authentication.""" podlove_detail_url = reverse("cast:api:audio_podlove_detail", kwargs={"pk": audio.pk}) r = api_client.get(podlove_detail_url, format="json") assert r.status_code == 200 - @pytest.mark.django_db def test_podlove_detail_endpoint_duration(self, api_client, audio): """Test whether microseconds get stripped away from duration via api - they have to be for podlove player to work. @@ -108,7 +102,27 @@ def test_podlove_detail_endpoint_duration(self, api_client, audio): r = api_client.get(podlove_detail_url, format="json") assert "." not in r.json()["duration"] - @pytest.mark.django_db + def test_podlove_detail_endpoint_includes_link_to_episode(self, api_client, podcast_episode): + """Test whether the podlove detail endpoint includes a link to the episode.""" + audio = podcast_episode.podcast_audio + podlove_detail_url = reverse("cast:api:audio_podlove_detail", kwargs={"pk": audio.pk}) + + r = api_client.get(podlove_detail_url, format="json") + assert r.status_code == 200 + + # link is always included, might be empty + assert "link" in r.json() + + # explicitly set episode_id FIXME: only works if there are multiple episodes for audio + podlove_detail_url_with_episode_id = f"{podlove_detail_url}?episode_id={podcast_episode.pk}" + + r = api_client.get(podlove_detail_url_with_episode_id, format="json") + assert r.status_code == 200 + + podlove_data = r.json() + assert "link" in podlove_data + assert podlove_data["link"] == podcast_episode.full_url + def test_podlove_detail_endpoint_chaptermarks(self, api_client, audio, chaptermarks): """Test whether chaptermarks get delivered via podlove endpoint.""" print("chaptermarks: ", chaptermarks) @@ -124,42 +138,18 @@ def test_podlove_detail_endpoint_chaptermarks(self, api_client, audio, chapterma assert chapters[-1]["title"] == "coughing" -class TestRequest: - @classmethod - def setup_class(cls): - cls.list_url = reverse("cast:api:request_list") - - @pytest.mark.django_db - def test_request_list_endpoint_without_authentication(self, api_client): - """Should not be accessible without authentication.""" - r = api_client.get(self.list_url, format="json") - assert r.status_code == 403 - - @pytest.mark.django_db - def test_request_list_endpoint_with_authentication(self, api_client): - """Check for list result when accessing the list endpoint - being logged in. - """ - user = UserFactory() - api_client.login(username=user.username, password="password") - r = api_client.get(self.list_url, format="json") - # dont redirect to login page - assert r.status_code == 200 - assert "results" in r.json() - - class TestCommentTrainingData: + pytestmark = pytest.mark.django_db + @classmethod def setup_class(cls): cls.url = reverse("cast:api:comment-training-data") - @pytest.mark.django_db def test_get_comment_training_data_without_authentication(self, api_client): """Should not be accessible without authentication.""" r = api_client.get(self.url, format="json") assert r.status_code == 403 - @pytest.mark.django_db def test_get_comment_training_data_with_authentication(self, api_client): """Check for list result when accessing the training data endpoint being logged in.""" user = UserFactory() @@ -167,61 +157,3 @@ def test_get_comment_training_data_with_authentication(self, api_client): r = api_client.get(self.url, format="json") assert r.status_code == 200 assert r.json() == [] - - -# @pytest.mark.django_db -# def test_request_list_endpoint_non_bulk_insert(self, api_client, access_log_path): -# user = UserFactory() -# api_client.login(username=user.username, password="password") -# df = get_dataframe_from_position(access_log_path, start_position=0) -# raw_rows = df.fillna("").to_dict(orient="rows") -# rows = pandas_rows_to_dict(raw_rows) -# row = rows[0] -# r = api_client.post(self.list_url, data=row, format="json") -# assert r.status_code == 201 - -# @pytest.mark.django_db -# def test_request_list_endpoint_bulk_insert(self, api_client, access_log_path): -# user = UserFactory() -# api_client.login(username=user.username, password="password") -# df = get_dataframe_from_position(access_log_path, start_position=0) -# raw_rows = df.fillna("").to_dict(orient="rows") -# rows = pandas_rows_to_dict(raw_rows) -# r = api_client.post(self.list_url, data=rows, format="json") -# assert r.status_code == 201 -# assert Request.objects.count() == df.shape[0] - -# @pytest.mark.django_db -# def test_request_list_endpoint_incremental_insert( -# self, api_client, access_log_path -# ): -# user = UserFactory() -# api_client.login(username=user.username, password="password") -# Request.objects.all().delete() -# -# # insert just first row -# df = get_dataframe_from_position(access_log_path, start_position=0) -# raw_rows = df.fillna("").to_dict(orient="rows") -# rows = pandas_rows_to_dict(raw_rows) -# row = rows[0] -# r = api_client.post(self.list_url, data=row, format="json") -# assert r.status_code == 201 -# -# # get last position (should be 4 because first 5 are the same) -# last_request = Request.objects.all().order_by("-timestamp")[0] -# last_position = get_last_request_position(access_log_path, last_request) -# assert last_position == 4 -# -# # insert starting at position 4 -# df = get_dataframe_from_position(access_log_path, start_position=last_position) -# raw_rows = df.fillna("").to_dict(orient="rows") -# rows = pandas_rows_to_dict(raw_rows) -# r = api_client.post(self.list_url, data=rows, format="json") -# assert r.status_code == 201 -# -# # assert number of unique lines in access.log and objects in database are equal -# # we omitted some lines intentionally -# number_of_unique_lines = 0 -# with open(access_log_path) as f: -# number_of_unique_lines = len(set([l for l in f])) -# assert Request.objects.count() == number_of_unique_lines diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py deleted file mode 100644 index d6dc7939..00000000 --- a/tests/test_dashboard.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.urls import reverse - -import pytest - -from .factories import UserFactory - - -class TestDashboard: - pytestmark = pytest.mark.django_db - - @classmethod - def setup_class(cls): - cls.dashboard_url = reverse("cast:dashboard") - - def test_get_dashboard_without_authentication(self, client): - r = client.get(self.dashboard_url) - # redirect to login page - assert r.status_code == 302 - - def test_get_dashboard_with_authentication(self, client): - user = UserFactory() - client.login(username=user.username, password="password") - r = client.get(self.dashboard_url) - # dont redirect to login page - assert r.status_code == 200 - assert "Dashboard" in r.content.decode("utf8") diff --git a/tests/test_feed.py b/tests/test_feed.py index 1164d51d..96722804 100644 --- a/tests/test_feed.py +++ b/tests/test_feed.py @@ -11,8 +11,15 @@ from cast.models import Post +def test_unknown_audio_format(): + pf = PodcastFeed() + with pytest.raises(Http404): + pf.set_audio_format("foobar") + + class TestFeedCreation: - @pytest.mark.django_db + pytestmark = pytest.mark.django_db + def test_add_artwork_true(self, dummy_handler, blog_with_artwork): ie = ITunesElements() ie.feed = {"title": "foobar", "link": "bar"} @@ -21,7 +28,6 @@ def test_add_artwork_true(self, dummy_handler, blog_with_artwork): assert "image" in dummy_handler.se assert "image" in dummy_handler.ee - @pytest.mark.django_db def test_add_artwork_false(self, dummy_handler, blog): ie = ITunesElements() ie.feed = {"title": "foobar", "link": "bar"} @@ -30,12 +36,6 @@ def test_add_artwork_false(self, dummy_handler, blog): assert "image" not in dummy_handler.se assert "image" not in dummy_handler.ee - def test_unknown_audio_format(self): - pf = PodcastFeed() - with pytest.raises(Http404): - pf.set_audio_format("foobar") - - @pytest.mark.django_db def test_itunes_categories(self, dummy_handler, blog_with_itunes_categories): blog = blog_with_itunes_categories ie = ITunesElements() @@ -55,7 +55,8 @@ def use_dummy_cache_backend(settings): class TestGeneratedFeeds: - @pytest.mark.django_db + pytestmark = pytest.mark.django_db + def test_get_latest_entries_feed(self, client, post, use_dummy_cache_backend): feed_url = reverse("cast:latest_entries_feed", kwargs={"slug": post.blog.slug}) @@ -66,7 +67,6 @@ def test_get_latest_entries_feed(self, client, post, use_dummy_cache_backend): assert "xml" in content assert post.title in content - @pytest.mark.django_db def test_get_podcast_m4a_feed_rss(self, client, podcast_episode, use_dummy_cache_backend): feed_url = reverse( "cast:podcast_feed_rss", @@ -80,7 +80,6 @@ def test_get_podcast_m4a_feed_rss(self, client, podcast_episode, use_dummy_cache assert "rss" in content assert podcast_episode.title in content - @pytest.mark.django_db def test_get_podcast_m4a_feed_atom(self, client, podcast_episode): feed_url = reverse( "cast:podcast_feed_atom", @@ -94,7 +93,6 @@ def test_get_podcast_m4a_feed_atom(self, client, podcast_episode): assert "feed" in content assert podcast_episode.title in content - @pytest.mark.django_db def test_podcast_feed_contains_only_podcasts(self, client, post, podcast_episode, use_dummy_cache_backend): feed_url = reverse( "cast:podcast_feed_rss", @@ -107,7 +105,6 @@ def test_podcast_feed_contains_only_podcasts(self, client, post, podcast_episode assert len(d.entries) == 1 assert Post.objects.live().descendant_of(podcast_episode.blog).count() == 2 - @pytest.mark.django_db def test_podcast_feed_contains_visible_date_as_pubdate( self, client, podcast_episode_with_different_visible_date, use_dummy_cache_backend ): @@ -125,7 +122,6 @@ def test_podcast_feed_contains_visible_date_as_pubdate( date_from_feed = pytz.utc.localize(date_from_feed) assert date_from_feed == podcast_episode.visible_date - @pytest.mark.django_db def test_podcast_feed_contains_detail_information(self, client, podcast_episode): feed_url = reverse( "cast:podcast_feed_rss", diff --git a/tests/test_models.py b/tests/test_models.py index 9e64710b..c2e0614f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,17 +6,16 @@ class TestVideoModel: - @pytest.mark.django_db + pytestmark = pytest.mark.django_db + def test_get_all_video_paths(self, video): all_paths = list(video.get_all_paths()) assert len(all_paths) == 1 - @pytest.mark.django_db def test_get_all_video_paths_with_poster(self, video_with_poster): all_paths = list(video_with_poster.get_all_paths()) assert len(all_paths) == 2 - @pytest.mark.django_db def test_get_all_video_paths_without_thumbnail(self, video): class Dummy: name = "foobar" @@ -49,51 +48,72 @@ def test_get_image_ids(self, gallery): class TestAudioModel: - @pytest.mark.django_db + pytestmark = pytest.mark.django_db + def test_get_file_formats(self, audio): assert audio.file_formats == "m4a" - @pytest.mark.django_db def test_get_file_names(self, audio): assert "test" in audio.get_audio_file_names() - @pytest.mark.django_db def test_get_name(self, audio): audio.title = None # make sure name is provided by file assert audio.name == "test" - @pytest.mark.django_db def test_get_name_with_title(self, audio): title = "foobar" audio.title = title assert audio.name == title - @pytest.mark.django_db def test_audio_str(self, audio): audio.title = None # make sure name is provided by file assert "1 - test" == str(audio) - @pytest.mark.django_db def test_audio_get_all_paths(self, audio): assert "cast_audio/test.m4a" in audio.get_all_paths() - @pytest.mark.django_db def test_audio_duration(self, audio): duration = audio._get_audio_duration(audio.m4a.path) assert duration == timedelta(microseconds=700000) - @pytest.mark.django_db def test_audio_create_duration(self, audio): duration = "00:01:01.00" audio._get_audio_duration = lambda x: duration audio.create_duration() assert audio.duration == duration - @pytest.mark.django_db def test_audio_podlove_url(self, audio): assert audio.podlove_url == "/cast/api/audios/podlove/1" - @pytest.mark.django_db + def test_get_episode_url_from_audio(self, podcast_episode): + audio = podcast_episode.podcast_audio + + # happy path - audio has episode and episode_id is set + audio.set_episode_id(podcast_episode.pk) + assert "http" in audio.episode_url + + # happy path - audio is only used by one episode + del audio._episode_id + assert "http" in audio.episode_url + + # sad path - audio is not used by any episode + podcast_episode.podcast_audio = None + podcast_episode.save() + assert audio.episode_url is None + + def test_get_episode_url_from_audio_with_multiple_episodes(self, podcast_episode, podcast_episode_with_same_audio): + audio = podcast_episode.podcast_audio + + # sad path - audio is used by multiple episodes + assert audio.episode_url is None + + # happy path - audio is used by multiple episodes but episode_id is set + audio.set_episode_id(podcast_episode.pk) + assert audio.episode_url == podcast_episode.full_url + + audio.set_episode_id(podcast_episode_with_same_audio.pk) + assert audio.episode_url == podcast_episode_with_same_audio.full_url + def test_audio_get_chaptermark_data_from_file_empty_on_value_error(self, audio): assert audio.get_chaptermark_data_from_file("mp3") == [] @@ -146,54 +166,48 @@ def test_get_all_file_paths(self, file_instance): class TestBlogModel: - @pytest.mark.django_db + pytestmark = pytest.mark.django_db + def test_blog_str(self, blog): assert blog.title == str(blog) - @pytest.mark.django_db def test_blog_author_null(self, blog): blog.author = None assert blog.author_name == blog.owner.get_full_name() - @pytest.mark.django_db def test_blog_author_not_null(self, blog): blog.author = "Foobar" assert blog.author_name == blog.author class TestPostModel: - @pytest.mark.django_db + pytestmark = pytest.mark.django_db + def test_post_slug(self, post): assert post.get_slug() == "test-entry" - @pytest.mark.django_db def test_post_has_audio(self, post): assert post.has_audio is False - @pytest.mark.django_db def test_post_has_audio_true(self, post, audio): post.podcast_audio = audio assert post.has_audio is True - @pytest.mark.django_db def test_post_comments_enabled(self, post, comments_enabled): post.comments_enabled = True post.blog.comments_enabled = True assert post.comments_are_enabled - @pytest.mark.django_db def test_post_comments_disabled_settings(self, post, comments_not_enabled): post.comments_enabled = True post.blog.comments_enabled = True assert not post.comments_are_enabled - @pytest.mark.django_db def test_post_comments_disabled_blog(self, post, comments_enabled): post.comments_enabled = True post.blog.comments_enabled = False assert not post.comments_are_enabled - @pytest.mark.django_db def test_post_comments_disabled_post(self, post, comments_enabled): post.comments_enabled = False post.blog.comments_enabled = True diff --git a/tests/test_post_add.py b/tests/test_post_add.py index 33fed8f6..e4cf6f3d 100644 --- a/tests/test_post_add.py +++ b/tests/test_post_add.py @@ -11,7 +11,7 @@ def test_get_add_form_post_not_authenticated(self, client, blog): add_url = reverse("wagtailadmin_pages:add_subpage", args=(blog.id,)) r = client.get(add_url) - # redirect to login + # redirect to log in assert r.status_code == 302 login_url = reverse("wagtailadmin_login") assert login_url in r.url @@ -86,7 +86,7 @@ def test_submit_add_form_post_authenticated_with_video(self, client, post_data_w # make sure there was a post added to the database assert post.title == post_data_wagtail["title"] - # make sure there was an video added + # make sure there was a video added assert post.videos.count() == 1 assert post.videos.first() == video @@ -112,7 +112,30 @@ def test_submit_add_form_post_authenticated_with_gallery(self, client, post_data # make sure there was a post added to the database assert post.title == post_data_wagtail["title"] - # make sure there was an gallery added + # make sure there was a gallery added assert post.galleries.count() == 1 assert post.galleries.first() == gallery assert list(post.galleries.first().images.all()) == list(gallery.images.all()) + + def test_submit_add_form_post_authenticated_with_code(self, client, post_data_wagtail, blog, video): + _ = client.login(username=blog.owner.username, password=blog.owner._password) + add_url = reverse("wagtailadmin_pages:add", args=("cast", "post", blog.id)) + post_data_wagtail["body-0-value-0-type"] = "code" + post_data_wagtail["body-0-value-0-value-language"] = "python" + post_data_wagtail["body-0-value-0-value-source"] = 'def hello_world():\n print("Hello World!")' + + r = client.post(add_url, post_data_wagtail) + + # make sure we are redirected to blog index + assert r.status_code == 302 + assert r.url == reverse("wagtailadmin_explore", args=(blog.id,)) + + post = Post.objects.get(slug=post_data_wagtail["slug"]) + + # make sure there was a post added to the database + assert post.title == post_data_wagtail["title"] + + # make sure there was a code block added + assert post.body.raw_data[0]["value"][0]["value"]["language"] == "python" + assert "hello_world" in post.body.raw_data[0]["value"][0]["value"]["source"] + assert "highlight" in str(post.body)