From 91b02b5e332aa4ac26fe3885cf334a103054f352 Mon Sep 17 00:00:00 2001 From: Skylar Saveland Date: Mon, 8 May 2023 23:46:57 -0700 Subject: [PATCH] Members-only content confactor (#142) Co-authored-by: Jonathan Bird --- py/dj/apps/g12f/admin.py | 1 + .../migrations/0068_add_subscriber_only.py | 18 ++++++++ py/dj/apps/g12f/models/zpage.py | 1 + py/dj/apps/g12f/serializers/zpage.py | 32 ++++++++++---- py/dj/apps/g12f/views/exception_handler.py | 33 ++++++++++++++ py/dj/apps/g12f/views/zpage.py | 41 +++++++++++++++--- py/dj/apps/tuzis/views.py | 7 +++ py/dj/free2z/settings.py | 1 + ts/react/free2z/src/Begin.tsx | 13 ++++-- ts/react/free2z/src/EditPage.tsx | 19 ++++++++ ts/react/free2z/src/PageDetail.tsx | 43 +++++++++++++++---- .../free2z/src/components/CommentVote.tsx | 3 -- .../src/components/LoginMessageStack.tsx | 6 ++- .../free2z/src/components/PageListRow2.tsx | 10 ++++- .../free2z/src/components/PageRenderer.tsx | 1 + .../src/components/SubscribeToCreator.tsx | 25 ++++++++--- .../free2z/src/components/zPageCarousel.tsx | 12 +++++- 17 files changed, 228 insertions(+), 38 deletions(-) create mode 100644 py/dj/apps/g12f/migrations/0068_add_subscriber_only.py create mode 100644 py/dj/apps/g12f/views/exception_handler.py diff --git a/py/dj/apps/g12f/admin.py b/py/dj/apps/g12f/admin.py index 7226affbfc8..284074cee40 100644 --- a/py/dj/apps/g12f/admin.py +++ b/py/dj/apps/g12f/admin.py @@ -89,6 +89,7 @@ class PageAdmin(admin.ModelAdmin): # above is free 'is_verified', 'is_published', + 'is_subscriber_only', 'vanity', 'p2paddr', 'p2pqr', diff --git a/py/dj/apps/g12f/migrations/0068_add_subscriber_only.py b/py/dj/apps/g12f/migrations/0068_add_subscriber_only.py new file mode 100644 index 00000000000..52b8c5a9ccb --- /dev/null +++ b/py/dj/apps/g12f/migrations/0068_add_subscriber_only.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2023-04-22 01:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('g12f', '0067_zpage_publish_at'), + ] + + operations = [ + migrations.AddField( + model_name='zpage', + name='is_subscriber_only', + field=models.BooleanField(default=False), + ), + ] diff --git a/py/dj/apps/g12f/models/zpage.py b/py/dj/apps/g12f/models/zpage.py index 3eb3826e2f9..752f896fc25 100644 --- a/py/dj/apps/g12f/models/zpage.py +++ b/py/dj/apps/g12f/models/zpage.py @@ -93,6 +93,7 @@ class zPage(models.Model): # TODO: remove completely is_funded = models.BooleanField(default=False) is_published = models.BooleanField(default=False) + is_subscriber_only = models.BooleanField(default=False) is_verified = models.BooleanField(default=False) f2z_score = models.DecimalField( diff --git a/py/dj/apps/g12f/serializers/zpage.py b/py/dj/apps/g12f/serializers/zpage.py index d4a66458c9e..3fc1813d492 100644 --- a/py/dj/apps/g12f/serializers/zpage.py +++ b/py/dj/apps/g12f/serializers/zpage.py @@ -36,6 +36,7 @@ class Meta: 'title', 'content', 'is_published', + 'is_subscriber_only', 'category', 'f2z_score', 'get_url', @@ -50,7 +51,8 @@ def __init__(self, *args, **kwargs): if hasattr(self, 'context') and 'request' in self.context: request = self.context['request'] self.fields['featured_image'].queryset = GenericFile.objects.filter( - owner=request.user) + owner=request.user + ) def validate_featured_image(self, value): if value: @@ -130,6 +132,7 @@ class Meta: # 'content', 'category', 'is_published', + 'is_subscriber_only', 'featured_image', # ##################### 'is_verified', @@ -143,14 +146,21 @@ class Meta: 'tags', ) read_only_fields = [ - 'free2zaddr', 'is_verified', - 'total', 'f2z_score', 'updated_at', 'created_at', - 'get_url', 'creator', 'featured_image', + 'free2zaddr', + 'is_verified', + 'total', + 'f2z_score', + 'updated_at', + 'created_at', + 'get_url', + 'creator', + 'featured_image', ] def get_content(self, instance): import markdown2 from django.utils.html import strip_tags + html = markdown2.markdown(instance.content) text = strip_tags(html) return f"{text[:365]}..." @@ -186,6 +196,7 @@ class Meta: 'content', 'category', 'is_published', + 'is_subscriber_only', 'total_raised', 'featured_image', # ##################### @@ -201,8 +212,13 @@ class Meta: 'publish_at', ) read_only_fields = [ - 'free2zaddr', 'is_verified' - 'total', 'f2z_score', 'comments', - 'created_at', 'updated_at', 'creator', - 'get_url', 'featured_image', + 'free2zaddr', + 'is_verified' 'total', + 'f2z_score', + 'comments', + 'created_at', + 'updated_at', + 'creator', + 'get_url', + 'featured_image', ] diff --git a/py/dj/apps/g12f/views/exception_handler.py b/py/dj/apps/g12f/views/exception_handler.py new file mode 100644 index 00000000000..3ea62842e03 --- /dev/null +++ b/py/dj/apps/g12f/views/exception_handler.py @@ -0,0 +1,33 @@ +from rest_framework.views import exception_handler +from rest_framework.exceptions import APIException +from ..serializers import zPageDetailSerializer + +# custom exception handler can be used to trigger a modal on a zPage + + +class RedirectWithModalException(APIException): + status_code = 200 + default_detail = "This is a custom redirect exception with modal flag." + default_code = "redirect_with_modal" + + def __init__(self, page, show_modal=True): + self.show_modal = show_modal + self.page = page + super().__init__(self.default_detail, self.default_code) + + +def custom_exception_handler(exc, context): + response = exception_handler(exc, context) + + if isinstance(exc, RedirectWithModalException): + # Swap out the content so that an intrepid person can't + # pull it out of the response + exc.page.content = "Subscribe for exclusive content" + page_serializer = zPageDetailSerializer(instance=exc.page) + + response.data = { + 'show_modal': exc.show_modal, + 'page': page_serializer.data + } + + return response diff --git a/py/dj/apps/g12f/views/zpage.py b/py/dj/apps/g12f/views/zpage.py index 588c399c7ff..b90c56fdd58 100644 --- a/py/dj/apps/g12f/views/zpage.py +++ b/py/dj/apps/g12f/views/zpage.py @@ -1,19 +1,22 @@ import uuid from django import http from django.db.utils import IntegrityError - from django.shortcuts import get_object_or_404, render +from django.utils import timezone + +from dj.apps.g12f.models.creator import Subscription from rest_framework import ( generics, viewsets, filters, exceptions, permissions ) from rest_framework import status from django.db.models import Q - from rest_framework.request import Request from rest_framework.response import Response from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView +from .exception_handler import RedirectWithModalException + from dj.apps.g12f.serializers import ( zPageListSerializer, zPageUpdateSerializer, @@ -102,10 +105,20 @@ def get_object(self): queryset = self.get_queryset() queryset = self.filter_queryset(queryset) pk = self.kwargs.get('pk') + qpk = Q(free2zaddr=pk) | Q(vanity__iexact=pk) try: - return queryset.filter(Q(free2zaddr=pk) | Q(vanity__iexact=pk)).get() + return queryset.filter(qpk).get() # TODO: could be made abstracter except zPage.DoesNotExist: + qs = zPage.objects.filter( + is_subscriber_only=True, + is_published=True, + ).filter(qpk) + if qs.count(): + raise RedirectWithModalException( + page=qs.get(), + show_modal=True + ) raise http.Http404 @@ -189,6 +202,7 @@ def get_queryset(self): has_auth = request.user.is_authenticated is_owner = Q(creator=request.user) published = Q(is_published=True) + if has_auth: published = published | is_owner in_search = published @@ -272,8 +286,25 @@ def get_queryset(self): ) if self.action == 'retrieve': - # TODO: members-only - return zPage.objects.filter(published) + user = request.user + + if user.is_anonymous: + return zPage.objects.filter( + published & Q(is_subscriber_only=False)) + else: + # TODO: this might be a little expensive + # to do on each retrieve for a page... + # get all the creators that user has subscribed to + star_ids = Subscription.objects.filter( + fan=user, expires__gte=timezone.now(), + ).values_list( + "star_id", flat=True + ) + return zPage.objects.filter( + Q(is_subscriber_only=False, is_published=True) | + Q(creator__id__in=star_ids, is_published=True) | + is_owner, + ) if self.action in ['update', 'partial_update', 'destroy']: if request.user.is_anonymous: diff --git a/py/dj/apps/tuzis/views.py b/py/dj/apps/tuzis/views.py index d2717370049..eeb76fd5d38 100644 --- a/py/dj/apps/tuzis/views.py +++ b/py/dj/apps/tuzis/views.py @@ -31,6 +31,13 @@ def post(self, request, *args, **kwargs): status=status.HTTP_400_BAD_REQUEST, ) + # should we set an automatic minimum member price to fix this bug? + if not star.member_price: + return Response( + data={'error': 'Creator did not set member price'}, + status=status.HTTP_400_BAD_REQUEST, + ) + # doesn't have enough tuzis if fan.tuzis < star.member_price: return Response( diff --git a/py/dj/free2z/settings.py b/py/dj/free2z/settings.py index 274edef7ff4..d4f0e1e0dfb 100644 --- a/py/dj/free2z/settings.py +++ b/py/dj/free2z/settings.py @@ -113,6 +113,7 @@ 'rest_framework.authentication.BasicAuthentication', 'knox.auth.TokenAuthentication', ], + "EXCEPTION_HANDLER": "dj.apps.g12f.views.exception_handler.custom_exception_handler", } SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') diff --git a/ts/react/free2z/src/Begin.tsx b/ts/react/free2z/src/Begin.tsx index 4f17a9c3101..2be554ddb2e 100644 --- a/ts/react/free2z/src/Begin.tsx +++ b/ts/react/free2z/src/Begin.tsx @@ -36,7 +36,7 @@ function Begin() { const navigate = useNavigate() const [_, setLoading] = useGlobalState("loading") const [creator, setCreator] = useGlobalState("creator") - const [redirect, ___] = useGlobalState("redirect") + const [redirect, setRedirect] = useGlobalState("redirect") const [loginModal, setLoginModal] = useGlobalState("loginModal") useEffect(() => { @@ -45,17 +45,24 @@ function Begin() { axios .get("/api/auth/user/") .then((res) => { + // just to be paranoid + // Login to subscribe to zpage and then exit + // - don't get stuck. + let n = redirect + setRedirect("") + setCreator(res.data) setLoginModal(false) setLoading(false) - if (redirect) { - navigate(redirect) + if (n) { + navigate(n) } else { navigate("/profile") } }) .catch((res) => { // setLoginModal(false) + // setRedirect("") setLoading(false) // setCreator({} as Creator) }) diff --git a/ts/react/free2z/src/EditPage.tsx b/ts/react/free2z/src/EditPage.tsx index fc0f2c8e0ae..f5213eba9cd 100644 --- a/ts/react/free2z/src/EditPage.tsx +++ b/ts/react/free2z/src/EditPage.tsx @@ -52,6 +52,7 @@ export default function EditPage() { // featured_image: null, vanity: "", is_published: false, + is_subscriber_only: false, // category: "", tags: [] as Tag[], f2z_score: "0", @@ -559,6 +560,24 @@ export default function EditPage() { }} /> + ) => { + setPage({ + ...page, + is_subscriber_only: !page.is_subscriber_only, + }) + }} + /> + } + label="Subscriber only content" + /> + + { diff --git a/ts/react/free2z/src/PageDetail.tsx b/ts/react/free2z/src/PageDetail.tsx index 66f26aaa73d..55afff4a87f 100644 --- a/ts/react/free2z/src/PageDetail.tsx +++ b/ts/react/free2z/src/PageDetail.tsx @@ -1,11 +1,13 @@ import { useEffect, useState } from "react" import { useParams } from "react-router" + import axios from "axios" import { AlertColor } from "@mui/material" import PageRenderer, { PageInterface } from "./components/PageRenderer" import { useGlobalState } from "./state/global" import { Tag } from "./components/TagFilterMultiSelect" +import SubscribeToCreator from "./components/SubscribeToCreator" export interface PageComment { created_at: string @@ -15,6 +17,7 @@ export interface PageComment { export default function PageDetail() { const setSnackbarState = useGlobalState("snackbar")[1] const [loading, setLoading] = useGlobalState("loading") + const [modalOpen, setModalOpen] = useState(false) const [page, setPage] = useState({ title: "", @@ -29,6 +32,7 @@ export default function PageDetail() { vanity: "", is_published: true, is_verified: false, + is_subscriber_only: false, creator: {}, total: "42", f2z_score: "42", @@ -40,17 +44,34 @@ export default function PageDetail() { const params = useParams() useEffect(() => { - setLoading(true) + if (!modalOpen) { + setLoading(true) + } axios .get(`/api/zpage/${params.id}/`) .then((res) => { - // console.log("got page detail", res) - setPage({ - ...res.data, - tags: res.data.tags.map((t: string) => { - return { name: t } + if (res.data.show_modal) { + const updatedContentPage = { + ...res.data.page, + content: "Subscribe for access to this exclusive content!" + } + setModalOpen(true) + setPage({ + ...updatedContentPage, + tags: res.data.page.tags.map((t: string) => { + return { name: t } + }) }) - }) + + } else { + setPage({ + ...res.data, + tags: res.data.tags.map((t: string) => { + return { name: t } + }) + }) + setModalOpen(false) + } setLoading(false) }) .catch((res) => { @@ -62,12 +83,18 @@ export default function PageDetail() { }) setLoading(false) }) - }, []) + }, [modalOpen]) if (!page.title) return null return ( <> + ) diff --git a/ts/react/free2z/src/components/CommentVote.tsx b/ts/react/free2z/src/components/CommentVote.tsx index 099bcd67ab5..48349c9b3dc 100644 --- a/ts/react/free2z/src/components/CommentVote.tsx +++ b/ts/react/free2z/src/components/CommentVote.tsx @@ -4,9 +4,6 @@ import { IconButton, useTheme } from "@mui/material"; import { CommentData } from "./DisplayThreadedComments"; import { useState } from 'react'; import { useGlobalState } from '../state/global'; -import LoginMessageStack from './LoginMessageStack'; -import { useLocation } from 'react-router-dom'; -import CreatorLoginModal from '../CreatorLoginModal'; import './UpDownPage.css' diff --git a/ts/react/free2z/src/components/LoginMessageStack.tsx b/ts/react/free2z/src/components/LoginMessageStack.tsx index 4be3c329de0..13d37f122fd 100644 --- a/ts/react/free2z/src/components/LoginMessageStack.tsx +++ b/ts/react/free2z/src/components/LoginMessageStack.tsx @@ -1,6 +1,6 @@ import { LoginTwoTone } from "@mui/icons-material" import { Stack, Typography, Tooltip, IconButton } from "@mui/material" -import { useLocation, useNavigate } from "react-router-dom" +import { useNavigate, useLocation } from "react-router-dom" import { useGlobalState } from "../state/global" type MessageProps = { @@ -10,6 +10,7 @@ type MessageProps = { export default function LoginMessageStack(props: MessageProps) { const [_, setRedirect] = useGlobalState("redirect") + // const [loginModal, setLoginModal] = useGlobalState("loginModal") const navigate = useNavigate() const location = useLocation() @@ -25,8 +26,11 @@ export default function LoginMessageStack(props: MessageProps) { { + // if someone is already subscribed, + // they will be let in on redirect setRedirect(location.pathname) navigate("/begin") + // setLoginModal(true) }} > + {page.is_subscriber_only && + theme.palette.secondary.main + }} + >Subscribers only + } {page.content} diff --git a/ts/react/free2z/src/components/PageRenderer.tsx b/ts/react/free2z/src/components/PageRenderer.tsx index 918144ecb5a..02585dab2db 100644 --- a/ts/react/free2z/src/components/PageRenderer.tsx +++ b/ts/react/free2z/src/components/PageRenderer.tsx @@ -50,6 +50,7 @@ export interface PageInterface { featured_image?: FeaturedImage is_verified: boolean is_published: boolean + is_subscriber_only: boolean total: string f2z_score: string created_at: string diff --git a/ts/react/free2z/src/components/SubscribeToCreator.tsx b/ts/react/free2z/src/components/SubscribeToCreator.tsx index 9ee2d9b0f32..511a0a31ca1 100644 --- a/ts/react/free2z/src/components/SubscribeToCreator.tsx +++ b/ts/react/free2z/src/components/SubscribeToCreator.tsx @@ -1,8 +1,11 @@ import axios from "axios" -import { useLocation, useNavigate } from "react-router-dom" +import { useNavigate } from "react-router-dom" -import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Stack } from "@mui/material" +import { + Button, Dialog, DialogActions, DialogContent, DialogContentText, + DialogTitle, Stack, +} from "@mui/material" import { PublicCreator } from "../CreatorDetail" import { useGlobalState } from "../state/global" import Cookies from "js-cookie" @@ -15,12 +18,14 @@ type SubscribeToCreatorProps = { setShowPay: React.Dispatch>, // username: string, star: PublicCreator, - nav?: boolean + nav?: boolean, + memberPage?: string, } export default function SubscribeToCreator(props: SubscribeToCreatorProps) { const { showPay, setShowPay, star } = props const [fan, setFan] = useGlobalState("creator") + const [redirect, setRedirect] = useGlobalState("redirect") const setSnackbar = useGlobalState("snackbar")[1] const navigate = useNavigate() @@ -63,7 +68,9 @@ export default function SubscribeToCreator(props: SubscribeToCreatorProps) { ) : ( - + )} { // console.log("CATCH", err) @@ -116,13 +127,13 @@ export default function SubscribeToCreator(props: SubscribeToCreatorProps) {