add membership handling endpoints
This commit is contained in:
parent
dc6276803d
commit
3fe26fa6a9
|
|
@ -136,4 +136,4 @@ class SnapshotRestoreResponseSerializer(serializers.Serializer):
|
|||
class TokenResponseSerializer(serializers.Serializer):
|
||||
"""serialize token response"""
|
||||
|
||||
token = serializers.CharField()
|
||||
token = serializers.CharField(allow_null=True)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
"""membership platform serializers"""
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class MembershipUserSerializer(serializers.Serializer):
|
||||
"""serialize user"""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
username = serializers.CharField()
|
||||
|
||||
|
||||
class SponsortierSerializer(serializers.Serializer):
|
||||
"""serialize sponsor tier"""
|
||||
|
||||
tier_id = serializers.IntegerField()
|
||||
name = serializers.CharField()
|
||||
description = serializers.CharField()
|
||||
max_subs = serializers.IntegerField()
|
||||
|
||||
|
||||
class MembershipProfileSerializer(serializers.Serializer):
|
||||
"""serialize membership profile"""
|
||||
|
||||
id = serializers.IntegerField()
|
||||
user = MembershipUserSerializer()
|
||||
sponsor_tier = SponsortierSerializer()
|
||||
subscription_count = serializers.IntegerField()
|
||||
subscription_is_max = serializers.BooleanField()
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"""
|
||||
interact with members.tubearchivist.com
|
||||
code related to sponsor perks
|
||||
"""
|
||||
|
||||
from os import environ
|
||||
|
||||
import requests
|
||||
from appsettings.src.config import AppConfig
|
||||
from common.src.helper import get_channels
|
||||
from common.src.ta_redis import RedisArchivist
|
||||
|
||||
|
||||
class Membership:
|
||||
"""membership"""
|
||||
|
||||
BASE_URL = environ.get("MB_URL", "https://members.tubearchivist.com")
|
||||
REDIS_KEY = "MB:KEY"
|
||||
|
||||
def __init__(self):
|
||||
self.config = AppConfig().config
|
||||
|
||||
def get_profile(self):
|
||||
"""get profile"""
|
||||
response = requests.get(
|
||||
f"{self.BASE_URL}/api/profile/me/",
|
||||
headers=self._get_headers(),
|
||||
timeout=30,
|
||||
)
|
||||
return response
|
||||
|
||||
def _get_headers(self):
|
||||
"""get headers with api key"""
|
||||
token = RedisArchivist().get_message_dict(self.REDIS_KEY)
|
||||
if not token:
|
||||
raise ValueError("expected MB_API_KEY")
|
||||
|
||||
token_str = token["token"]
|
||||
|
||||
return {"Authorization": f"Token {token_str}"}
|
||||
|
||||
def sync_subs(self):
|
||||
"""sync subscriptions, works if within max limits"""
|
||||
to_sync = self._get_to_sync()
|
||||
response = requests.post(
|
||||
f"{self.BASE_URL}/api/profile/subscription/?delete=true",
|
||||
headers=self._get_headers(),
|
||||
json=to_sync,
|
||||
timeout=30,
|
||||
)
|
||||
return response
|
||||
|
||||
def _get_to_sync(self):
|
||||
"""get channels to sync"""
|
||||
to_sync = []
|
||||
subscribed = get_channels(subscribed_only=True)
|
||||
for channel in subscribed:
|
||||
overwrites = channel.get("channel_overwrites", {})
|
||||
to_sync.append(
|
||||
{
|
||||
"channel_id": channel["channel_id"],
|
||||
"notify_videos": self._notify_videos(overwrites),
|
||||
"notify_streams": self._notify_streams(overwrites),
|
||||
"notify_shorts": self._notify_shorts(overwrites),
|
||||
}
|
||||
)
|
||||
|
||||
return to_sync
|
||||
|
||||
def _notify_videos(self, overwrites: dict) -> bool:
|
||||
"""notify videos"""
|
||||
if overwrites.get("subscriptions_channel_size") == 0:
|
||||
return False
|
||||
|
||||
return self.config["subscriptions"].get("channel_size") != 0
|
||||
|
||||
def _notify_streams(self, overwrites: dict) -> bool:
|
||||
"""notify streams"""
|
||||
if overwrites.get("subscriptions_live_channel_size") == 0:
|
||||
return False
|
||||
|
||||
return self.config["subscriptions"].get("live_channel_size") != 0
|
||||
|
||||
def _notify_shorts(self, overwrites: dict) -> bool:
|
||||
"""notify shorts"""
|
||||
if overwrites.get("subscriptions_shorts_channel_size") == 0:
|
||||
return False
|
||||
|
||||
return self.config["subscriptions"].get("shorts_channel_size") != 0
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"""all app settings API urls"""
|
||||
|
||||
from appsettings import views
|
||||
from appsettings import views, views_mb
|
||||
from django.urls import path
|
||||
|
||||
urlpatterns = [
|
||||
|
|
@ -44,4 +44,19 @@ urlpatterns = [
|
|||
views.TokenView.as_view(),
|
||||
name="api-token",
|
||||
),
|
||||
path(
|
||||
"membership/profile/",
|
||||
views_mb.MembershipProfileView.as_view(),
|
||||
name="api-membership-profile",
|
||||
),
|
||||
path(
|
||||
"membership/sync/",
|
||||
views_mb.MembershipSubscriptionSync.as_view(),
|
||||
name="api-membership-sync",
|
||||
),
|
||||
path(
|
||||
"membership/token/",
|
||||
views_mb.MembershipToken.as_view(),
|
||||
name="api-membership-token",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
"""membership platform views"""
|
||||
|
||||
from json import JSONDecodeError
|
||||
|
||||
from appsettings.serializers import TokenResponseSerializer
|
||||
from appsettings.serializers_mb import MembershipProfileSerializer
|
||||
from appsettings.src.membership import Membership
|
||||
from common.serializers import ErrorResponseSerializer
|
||||
from common.src.ta_redis import RedisArchivist
|
||||
from common.views_base import AdminOnly, ApiBaseView
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.response import Response
|
||||
|
||||
|
||||
class MembershipProfileView(ApiBaseView):
|
||||
"""resolves to /api/appsettings/membership/profile/
|
||||
GET: get profile status
|
||||
"""
|
||||
|
||||
permission_classes = [AdminOnly]
|
||||
|
||||
@staticmethod
|
||||
@extend_schema(
|
||||
responses={
|
||||
200: OpenApiResponse(MembershipProfileSerializer()),
|
||||
400: OpenApiResponse(
|
||||
ErrorResponseSerializer(), description="bad request"
|
||||
),
|
||||
}
|
||||
)
|
||||
def get(request):
|
||||
"""get profile"""
|
||||
|
||||
try:
|
||||
profile_response = Membership().get_profile()
|
||||
except ValueError as error:
|
||||
error = ErrorResponseSerializer({"message": str(error)})
|
||||
return Response(error.data, status=400)
|
||||
|
||||
try:
|
||||
response_json = profile_response.json()
|
||||
except JSONDecodeError:
|
||||
code = profile_response.status_code
|
||||
message = f"Connection to remote server failed: {code}"
|
||||
error_message = {"message": message}
|
||||
return Response(error_message, status=400)
|
||||
|
||||
if profile_response.status_code == 403:
|
||||
message = response_json.get("detail", "undefined error")
|
||||
error_message = {"message": message}
|
||||
return Response(error_message, status=400)
|
||||
|
||||
serializer = MembershipProfileSerializer(data=response_json)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class MembershipSubscriptionSync(ApiBaseView):
|
||||
"""resolves to /api/appsettings/membership/sync/
|
||||
POST: trigger sync task
|
||||
"""
|
||||
|
||||
permission_classes = [AdminOnly]
|
||||
|
||||
@staticmethod
|
||||
def post(request):
|
||||
"""post request"""
|
||||
response = Membership().sync_subs()
|
||||
|
||||
if not response.ok:
|
||||
try:
|
||||
response_json = response.json()
|
||||
message = response_json.get("detail", "undefined error")
|
||||
except JSONDecodeError:
|
||||
code = response.status_code
|
||||
message = f"Connection to remote server failed: {code}"
|
||||
|
||||
error_message = {"message": message}
|
||||
return Response(error_message, status=400)
|
||||
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class MembershipToken(ApiBaseView):
|
||||
"""resolves to /api/appsettings/membership/token/
|
||||
GET: get masked token
|
||||
POST: add token
|
||||
DELETE: delete token
|
||||
"""
|
||||
|
||||
permission_classes = [AdminOnly]
|
||||
REDIS_KEY = "MB:KEY"
|
||||
|
||||
def get(self, request):
|
||||
"""get token"""
|
||||
token = RedisArchivist().get_message_dict(self.REDIS_KEY)
|
||||
if token:
|
||||
serializer = TokenResponseSerializer(data=token)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.data
|
||||
else:
|
||||
data = {"token": None}
|
||||
|
||||
return Response(data)
|
||||
|
||||
def post(self, request):
|
||||
"""add token"""
|
||||
serializer = TokenResponseSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
RedisArchivist().set_message(
|
||||
self.REDIS_KEY, message=serializer.data, save=True
|
||||
)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def delete(self, request):
|
||||
"""delete token"""
|
||||
RedisArchivist().del_message(self.REDIS_KEY)
|
||||
|
||||
return Response(status=204)
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import APIClient, { ApiError } from '../functions/APIClient';
|
||||
import LoadingIndicator from './LoadingIndicator';
|
||||
|
||||
type ApiTokenResponse = {
|
||||
token: string;
|
||||
};
|
||||
|
||||
type ProfileUserType = {
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
|
||||
type SponsorTierType = {
|
||||
tier_id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
max_subs: number;
|
||||
};
|
||||
|
||||
type ProfileResponseType = {
|
||||
id: number;
|
||||
user: ProfileUserType;
|
||||
sponsor_tier: SponsorTierType;
|
||||
subscription_count: number;
|
||||
subscription_is_max: boolean;
|
||||
};
|
||||
|
||||
export default function MembershipAppsettings({ show_help_text }: { show_help_text: boolean }) {
|
||||
const [inputType, setInputType] = useState('password');
|
||||
const [membershipApiToken, setMembershipApiToken] = useState<string | null>(null);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [profileResponse, setProfileResponse] = useState<ProfileResponseType | null>(null);
|
||||
const [profileResponseError, setProfileResponseError] = useState('');
|
||||
const [isLoadingProfile, setIsLoadingProfile] = useState(false);
|
||||
const [isLoadingSync, setIsLoadingSync] = useState(false);
|
||||
const [subSyncMessage, setSubSyncMessage] = useState('');
|
||||
|
||||
const fetchMembershipToken = async () => {
|
||||
const apiTokenResponse = await APIClient<ApiTokenResponse>(
|
||||
'/api/appsettings/membership/token/',
|
||||
);
|
||||
setMembershipApiToken(apiTokenResponse.data?.token || null);
|
||||
};
|
||||
|
||||
const deleteMembershipToken = async () => {
|
||||
await APIClient('/api/appsettings/membership/token/', { method: 'DELETE' });
|
||||
setMembershipApiToken(null);
|
||||
setProfileResponseError('');
|
||||
setProfileResponse(null);
|
||||
};
|
||||
|
||||
const updateToken = async () => {
|
||||
const { data } = await APIClient<ApiTokenResponse>('/api/appsettings/membership/token/', {
|
||||
method: 'POST',
|
||||
body: { token: newToken },
|
||||
});
|
||||
if (data) {
|
||||
setNewToken(null);
|
||||
setMembershipApiToken(data.token);
|
||||
setInputType('password');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembershipToken();
|
||||
}, []);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
setProfileResponse(null);
|
||||
setProfileResponseError('');
|
||||
setSubSyncMessage('');
|
||||
|
||||
try {
|
||||
setIsLoadingProfile(true);
|
||||
const { data } = await APIClient<ProfileResponseType>('/api/appsettings/membership/profile/');
|
||||
if (data) setProfileResponse(data);
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
if (apiError.status && apiError.message) {
|
||||
setProfileResponseError(apiError.message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSyncSubscriptions = async () => {
|
||||
setProfileResponseError('');
|
||||
setSubSyncMessage('');
|
||||
|
||||
try {
|
||||
setIsLoadingSync(true);
|
||||
await APIClient('/api/appsettings/membership/sync/', { method: 'POST' });
|
||||
setSubSyncMessage('Task created');
|
||||
} catch (error) {
|
||||
const apiError = error as ApiError;
|
||||
if (apiError.status && apiError.message) {
|
||||
setProfileResponseError(apiError.message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingSync(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleShowKey = () => {
|
||||
if (inputType === 'password') {
|
||||
setInputType('text');
|
||||
} else {
|
||||
setInputType('password');
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (value: string) => {
|
||||
setNewToken(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Membership</h2>
|
||||
{show_help_text && (
|
||||
<div className="help-text">
|
||||
<p>
|
||||
Unlock additional perks by sponsoring this project. More details on{' '}
|
||||
<a href="https://members.tubearchivist.com/" target="_blank" rel="noopener noreferrer">
|
||||
members.tubearchivist.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Enter the API token from{' '}
|
||||
<a href="https://members.tubearchivist.com/profile">
|
||||
members.tubearchivist.com/profile
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>Click on validate to verify everything is working.</li>
|
||||
<li>
|
||||
If you are subscribed to less channels than your sponsor tier allows, you can directly
|
||||
sync all your subscriptions here.
|
||||
</li>
|
||||
<ul>
|
||||
<li>Repeat the sync after changing subscriptions here.</li>
|
||||
<li>
|
||||
That will unsubscribe from channels on the membership platform if you are no longer
|
||||
subscribed here.
|
||||
</li>
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-box-wrapper">
|
||||
<div>
|
||||
<p>Membership API key</p>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type={inputType}
|
||||
value={newToken || membershipApiToken || ''}
|
||||
onChange={e => handleInputChange(e.target.value)}
|
||||
/>
|
||||
<div className="button-box">
|
||||
{(membershipApiToken || newToken) && (
|
||||
<button onClick={toggleShowKey}>{inputType === 'password' ? 'Show' : 'Hide'}</button>
|
||||
)}
|
||||
{newToken && (
|
||||
<>
|
||||
<button onClick={updateToken}>Save</button>
|
||||
<button onClick={() => setNewToken(null)}>Cancel</button>
|
||||
</>
|
||||
)}
|
||||
{membershipApiToken && (
|
||||
<button className="danger-button" onClick={deleteMembershipToken}>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{membershipApiToken && (
|
||||
<>
|
||||
<div>
|
||||
<p>Your Profile</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="button-box">
|
||||
{isLoadingProfile ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<button onClick={fetchProfile}>Validate</button>
|
||||
)}
|
||||
{isLoadingSync ? (
|
||||
<LoadingIndicator />
|
||||
) : (
|
||||
<button onClick={fetchSyncSubscriptions}>Sync Subscriptions</button>
|
||||
)}
|
||||
</div>
|
||||
{profileResponseError && <p className="danger-zone">Error: {profileResponseError}</p>}
|
||||
{profileResponse && (
|
||||
<>
|
||||
<p>
|
||||
Username: {profileResponse.user.username}
|
||||
<br />
|
||||
Sponsortier: {profileResponse.sponsor_tier.name} -{' '}
|
||||
{profileResponse.sponsor_tier.description}
|
||||
<br />
|
||||
Subscriptions: {profileResponse.subscription_count}/
|
||||
{profileResponse.sponsor_tier.max_subs}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{subSyncMessage && <p>Sync: {subSyncMessage}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import validateCookie from '../api/actions/validateCookie';
|
|||
import deletePoToken from '../api/actions/deletePoToken';
|
||||
import updatePoToken from '../api/actions/updatePoToken';
|
||||
import { useUserConfigStore } from '../stores/UserConfigStore';
|
||||
import MembershipAppsettings from '../components/MembershipAppsettings';
|
||||
|
||||
type SettingsApplicationReponses = {
|
||||
snapshots?: SnapshotListType;
|
||||
|
|
@ -911,6 +912,9 @@ const SettingsApplication = () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-box-item">
|
||||
<MembershipAppsettings show_help_text={userConfig.show_help_text} />
|
||||
</div>
|
||||
<div className="info-box-item">
|
||||
<h2>Snapshots</h2>
|
||||
{userConfig.show_help_text && (
|
||||
|
|
|
|||
|
|
@ -1231,6 +1231,10 @@ video:-webkit-full-screen {
|
|||
min-width: 300px;
|
||||
}
|
||||
|
||||
.settings-box-wrapper div {
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
/* settings */
|
||||
.settings-group {
|
||||
background-color: var(--highlight-bg);
|
||||
|
|
|
|||
Loading…
Reference in New Issue