Work on provider manager and UI

This commit is contained in:
Tiberiu Chibici 2020-10-18 19:36:26 +03:00
parent c8b2ef77e6
commit 45d50a1bff
11 changed files with 219 additions and 63 deletions

View File

@ -1,21 +1,20 @@
import json
import logging import logging
from typing import List, Dict, Union, Iterable from typing import List, Dict, Union, Iterable, Optional
from django.db import transaction
from YtManagerApp.models import VideoProviderConfig, Video, Subscription from YtManagerApp.models import VideoProviderConfig, Video, Subscription
from YtManagerApp.providers.video_provider import VideoProvider, InvalidURLError from YtManagerApp.providers.video_provider import VideoProvider, InvalidURLError, VideoProviderState
import json
from collections import namedtuple
log = logging.getLogger("VideoProviderManager") log = logging.getLogger("VideoProviderManager")
VideoProviderInfo = namedtuple('VideoProviderInfo', ['id', 'name', 'is_configured', 'description'])
class VideoProviderManager(object): class VideoProviderManager(object):
def __init__(self, registered_providers: List[VideoProvider]): def __init__(self, registered_providers: List[VideoProvider]):
self._registered_providers: Dict[str, VideoProvider] = {} self._registered_providers: Dict[str, VideoProvider] = {}
self._configured_providers: Dict[str, VideoProvider] = {}
self._pending_configs: Dict[str, VideoProviderConfig] = {} self._pending_configs: Dict[str, VideoProviderConfig] = {}
for rp in registered_providers: for rp in registered_providers:
self.register_provider(rp) self.register_provider(rp)
self._load() self._load()
@ -39,6 +38,28 @@ class VideoProviderManager(object):
self._configure(provider, self._pending_configs[provider.id]) self._configure(provider, self._pending_configs[provider.id])
del self._pending_configs[provider.id] del self._pending_configs[provider.id]
def configure_provider(self, provider_id: str, config: Optional[Dict[str, any]]):
provider = self.get(provider_id)
if config is not None:
provider.configure(config)
with transaction.atomic():
cfg, _ = VideoProviderConfig.objects.get_or_create(provider_id=provider_id)
cfg.settings = json.dumps(config)
cfg.save()
provider.state = VideoProviderState.OK
else:
provider.unconfigure()
VideoProviderConfig.objects.filter(provider_id=provider_id).delete()
provider.state = VideoProviderState.NOT_CONFIGURED
def get_provider_config(self, provider_id: str):
cfg = VideoProviderConfig.objects.filter(provider_id=provider_id).first()
if cfg is not None:
return json.loads(cfg.settings)
return None
def _load(self) -> None: def _load(self) -> None:
# Loads configuration from database # Loads configuration from database
for config in VideoProviderConfig.objects.all(): for config in VideoProviderConfig.objects.all():
@ -56,8 +77,8 @@ class VideoProviderManager(object):
def _configure(self, provider, config): def _configure(self, provider, config):
settings = json.loads(config.settings) settings = json.loads(config.settings)
provider.configure(settings) provider.configure(settings)
provider.state = VideoProviderState.OK
log.info(f"Configured video provider {provider.id}") log.info(f"Configured video provider {provider.id}")
self._configured_providers[provider.id] = provider
def get(self, item: Union[str, Subscription, Video]): def get(self, item: Union[str, Subscription, Video]):
""" """
@ -79,12 +100,13 @@ class VideoProviderManager(object):
:param url: :param url:
:return: :return:
""" """
for provider in self._configured_providers.values(): for provider in self._registered_providers.values():
try: if provider.state == VideoProviderState.OK:
provider.validate_subscription_url(url) try:
return provider.validate_subscription_url(url)
except InvalidURLError: return
pass except InvalidURLError:
pass
raise InvalidURLError("The given URL is not valid for any of the supported sites!") raise InvalidURLError("The given URL is not valid for any of the supported sites!")
@ -94,23 +116,16 @@ class VideoProviderManager(object):
:param url: :param url:
:return: :return:
""" """
for provider in self._configured_providers.values(): for provider in self._registered_providers.values():
try: if provider.state == VideoProviderState.OK:
provider.validate_subscription_url(url) try:
# Found the right provider provider.validate_subscription_url(url)
return provider.fetch_subscription(url) # Found the right provider
except InvalidURLError: return provider.fetch_subscription(url)
pass except InvalidURLError:
pass
raise InvalidURLError("The given URL is not valid for any of the supported sites!") raise InvalidURLError("The given URL is not valid for any of the supported sites!")
def get_available_providers(self) -> Iterable[VideoProviderInfo]: def get_available_providers(self) -> Iterable[VideoProvider]:
""" return self._registered_providers.values()
Gets a list of available providers and some basic information about them.
:return: List of dictionary entries
"""
for key, provider in self._registered_providers.items():
yield VideoProviderInfo(id=key,
name=provider.name,
description=provider.description,
is_configured=(key in self._configured_providers))

View File

@ -20,6 +20,9 @@ class DummyVideoProvider(VideoProvider):
def configure(self, configuration: Dict[str, Any]) -> None: def configure(self, configuration: Dict[str, Any]) -> None:
print(configuration) print(configuration)
def unconfigure(self):
pass
def validate_configuration(self, configuration: Dict[str, Any]): def validate_configuration(self, configuration: Dict[str, Any]):
print("Validating...") print("Validating...")
if configuration["number_of_something"] >= 10: if configuration["number_of_something"] >= 10:

View File

@ -1,4 +1,5 @@
from abc import abstractmethod, ABC from abc import abstractmethod, ABC
from enum import Enum
from typing import Dict, Iterable, List, Any from typing import Dict, Iterable, List, Any
from django.forms import Field from django.forms import Field
@ -6,6 +7,12 @@ from django.forms import Field
from YtManagerApp.models import Subscription, Video from YtManagerApp.models import Subscription, Video
class VideoProviderState(Enum):
NOT_CONFIGURED = 0
OK = 1
ERROR = 2
class ProviderValidationError(ValueError): class ProviderValidationError(ValueError):
""" """
Exception type thrown when validating configurations. Exception type thrown when validating configurations.
@ -44,6 +51,9 @@ class VideoProvider(ABC):
""" """
settings: Dict[str, Field] = {} settings: Dict[str, Field] = {}
def __init__(self):
self.state = VideoProviderState.NOT_CONFIGURED
@abstractmethod @abstractmethod
def configure(self, configuration: Dict[str, Any]) -> None: def configure(self, configuration: Dict[str, Any]) -> None:
""" """
@ -53,6 +63,14 @@ class VideoProvider(ABC):
""" """
pass pass
@abstractmethod
def unconfigure(self) -> None:
"""
Destroys video provider configuration
:return:
"""
pass
@abstractmethod @abstractmethod
def validate_configuration(self, configuration: Dict[str, Any]) -> None: def validate_configuration(self, configuration: Dict[str, Any]) -> None:
""" """

View File

@ -25,6 +25,10 @@ class YouTubeApiVideoProvider(VideoProvider):
self.__api_key = configuration['api_key'] self.__api_key = configuration['api_key']
self.__api = yt.YouTube(key=self.__api_key) self.__api = yt.YouTube(key=self.__api_key)
def unconfigure(self):
self.__api_key = None
self.__api = None
def validate_configuration(self, configuration: Dict[str, Any]): def validate_configuration(self, configuration: Dict[str, Any]):
# TODO: implement # TODO: implement
pass pass

View File

@ -199,4 +199,8 @@ img.muted {
font-size: 3rem; font-size: 3rem;
text-align: center; text-align: center;
line-height: 4rem; line-height: 4rem;
}
.provider-status {
font-size: 10rem;
} }

View File

@ -0,0 +1,6 @@
jQuery(function() {
});

View File

@ -0,0 +1,22 @@
{% extends 'YtManagerApp/controls/modal.html' %}
{% load crispy_forms_tags %}
{% block modal_title %}
{{ provider.name }} configuration
{% endblock modal_title %}
{% block modal_content %}
<form action="{% url 'modal_provider_config' provider.id %}" method="post">
{% csrf_token %}
{{ block.super }}
</form>
{% endblock %}
{% block modal_body %}
{{ form | crispy }}
{% endblock modal_body %}
{% block modal_footer %}
<input class="btn btn-primary" type="submit" value="Save">
<input class="btn btn-secondary" type="button" value="Cancel" data-dismiss="modal" aria-label="Cancel">
{% endblock modal_footer %}

View File

@ -3,34 +3,48 @@
{% block body %} {% block body %}
<div class="row"> {% if have_configured %}
<section>
{% for provider in providers %} <h3>Configured providers</h3>
{% if provider.is_configured %} <div class="row">
<div class="provider-wrapper col-2"> {% for provider in providers %}
<button class="btn btn-light "> {% if provider.is_configured %}
<img class="provider-logo" <div class="provider-wrapper col-2">
src="{% static provider.image_src %}" <button class="btn btn-light"
title="{{ provider.name }}"> data-modal="modal"
</button> data-modal-url="{% url 'modal_provider_config' provider.id %}">
</div> <img class="provider-logo"
{% endif %} src="{% static provider.image_src %}"
{% endfor %} title="{{ provider.name }}">
{% if provider.has_error %}
{% if have_unconfigured %} <span class="typcn typcn-warning provider-status text-warning"></span>
<div class="provider-wrapper col-2 dropdown"> {% else %}
<button id="btnAddProvider" <span class="typcn typcn-tick provider-status text-success"></span>
class="btn btn-light" {% endif %}
type="button" </button>
data-toggle="dropdown" </div>
aria-haspopup="true" aria-expanded="false"> {% endif %}
<span class="provider-logo typcn typcn-plus" {% endfor %}
title="Add provider"> </div>
</span> </section>
</button> {% endif %}
</div>
{% endif %}
</div>
{% if have_unconfigured %}
<h3>Not configured</h3>
<div class="row">
{% for provider in providers %}
{% if not provider.is_configured %}
<div class="provider-wrapper col-2">
<button class="btn btn-light"
data-modal="modal"
data-modal-url="{% url 'modal_provider_config' provider.id %}">
<img class="provider-logo"
src="{% static provider.image_src %}"
title="{{ provider.name }}">
</button>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -18,6 +18,7 @@ from django.conf.urls import include
from django.conf.urls.static import static from django.conf.urls.static import static
from django.urls import path from django.urls import path
from YtManagerApp.views.settings.provider_config import ProviderConfigView
from YtManagerApp.views.settings.providers_view import ProvidersView from YtManagerApp.views.settings.providers_view import ProvidersView
from .views import first_time from .views import first_time
from .views.actions import SyncNowView, DeleteVideoFilesView, DownloadVideoFilesView, MarkVideoWatchedView, \ from .views.actions import SyncNowView, DeleteVideoFilesView, DownloadVideoFilesView, MarkVideoWatchedView, \
@ -61,6 +62,8 @@ urlpatterns = [
path('modal/update_subscription/<int:pk>/', UpdateSubscriptionModal.as_view(), name='modal_update_subscription'), path('modal/update_subscription/<int:pk>/', UpdateSubscriptionModal.as_view(), name='modal_update_subscription'),
path('modal/delete_subscription/<int:pk>/', DeleteSubscriptionModal.as_view(), name='modal_delete_subscription'), path('modal/delete_subscription/<int:pk>/', DeleteSubscriptionModal.as_view(), name='modal_delete_subscription'),
path('modal/provider_config/<str:provider_id>/', ProviderConfigView.as_view(), name='modal_provider_config'),
# Pages # Pages
path('', index, name='home'), path('', index, name='home'),
path('settings/', SettingsView.as_view(), name='settings'), path('settings/', SettingsView.as_view(), name='settings'),

View File

@ -0,0 +1,59 @@
from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.exceptions import ValidationError
from django.views.generic import FormView
from YtManagerApp.providers.video_provider import VideoProvider, ProviderValidationError
from YtManagerApp.services import Services
from YtManagerApp.views.controls.modal import ModalMixin
class ProviderConfigForm(forms.Form):
def __init__(self, *args, **kwargs):
self.provider_id = kwargs.pop('provider_id', None)
super().__init__(*args, **kwargs)
if self.provider_id is not None:
provider: VideoProvider = Services.videoProviderManager().get(self.provider_id)
for key, field in provider.settings.items():
self.fields[key] = field
def clean(self):
cleaned_data = super().clean()
provider: VideoProvider = Services.videoProviderManager().get(self.provider_id)
try:
provider.validate_configuration(cleaned_data)
except ProviderValidationError as ex:
raise ValidationError(ex.field_messages)
class ProviderConfigView(LoginRequiredMixin, ModalMixin, FormView):
template_name = 'YtManagerApp/controls/provider_config_modal.html'
form_class = ProviderConfigForm
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['provider_id'] = self.kwargs['provider_id']
return kwargs
def get_context_data(self, **kwargs):
data = super().get_context_data(**kwargs)
data['provider'] = Services.videoProviderManager().get(self.kwargs['provider_id'])
return data
def get_initial(self):
initial = super().get_initial()
cfg = Services.videoProviderManager().get_provider_config(self.kwargs['provider_id'])
if cfg is not None:
initial.update(cfg)
def form_valid(self, form):
try:
Services.videoProviderManager().configure_provider(self.kwargs['provider_id'], form.cleaned_data)
except Exception as ex:
super().modal_response(form, success=False, error_msg='Configuration of provider failed! ' + str(ex))
return super().form_valid(form)

View File

@ -1,11 +1,12 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView from django.views.generic import TemplateView
from YtManagerApp.providers.video_provider import VideoProviderState
from YtManagerApp.services import Services from YtManagerApp.services import Services
from collections import namedtuple from collections import namedtuple
VideoProviderInfoViewModel = namedtuple('VideoProviderInfoViewModel', VideoProviderInfoViewModel = namedtuple('VideoProviderInfoViewModel',
['id', 'name', 'is_configured', 'image_src']) ['id', 'name', 'is_configured', 'has_error', 'image_src'])
class ProvidersView(LoginRequiredMixin, TemplateView): class ProvidersView(LoginRequiredMixin, TemplateView):
@ -16,16 +17,23 @@ class ProvidersView(LoginRequiredMixin, TemplateView):
providers = [] providers = []
have_unconfigured = False have_unconfigured = False
have_configured = False
for provider in Services.videoProviderManager().get_available_providers(): for provider in Services.videoProviderManager().get_available_providers():
providers.append(VideoProviderInfoViewModel( providers.append(VideoProviderInfoViewModel(
id=provider.id, id=provider.id,
name=provider.name, name=provider.name,
is_configured=provider.is_configured, is_configured=provider.state != VideoProviderState.NOT_CONFIGURED,
has_error=provider.state == VideoProviderState.ERROR,
image_src=f"YtManagerApp/img/video_providers/{provider.id}.png" image_src=f"YtManagerApp/img/video_providers/{provider.id}.png"
)) ))
if not provider.is_configured:
if provider.state != VideoProviderState.NOT_CONFIGURED:
have_configured = True
if provider.state == VideoProviderState.NOT_CONFIGURED:
have_unconfigured = True have_unconfigured = True
context['providers'] = sorted(providers, key=lambda x: x.name) context['providers'] = sorted(providers, key=lambda x: x.name)
context['have_unconfigured'] = have_unconfigured context['have_unconfigured'] = have_unconfigured
context['have_configured'] = have_configured
return context return context