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
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.providers.video_provider import VideoProvider, InvalidURLError
import json
from collections import namedtuple
from YtManagerApp.providers.video_provider import VideoProvider, InvalidURLError, VideoProviderState
log = logging.getLogger("VideoProviderManager")
VideoProviderInfo = namedtuple('VideoProviderInfo', ['id', 'name', 'is_configured', 'description'])
class VideoProviderManager(object):
def __init__(self, registered_providers: List[VideoProvider]):
self._registered_providers: Dict[str, VideoProvider] = {}
self._configured_providers: Dict[str, VideoProvider] = {}
self._pending_configs: Dict[str, VideoProviderConfig] = {}
for rp in registered_providers:
self.register_provider(rp)
self._load()
@ -39,6 +38,28 @@ class VideoProviderManager(object):
self._configure(provider, 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:
# Loads configuration from database
for config in VideoProviderConfig.objects.all():
@ -56,8 +77,8 @@ class VideoProviderManager(object):
def _configure(self, provider, config):
settings = json.loads(config.settings)
provider.configure(settings)
provider.state = VideoProviderState.OK
log.info(f"Configured video provider {provider.id}")
self._configured_providers[provider.id] = provider
def get(self, item: Union[str, Subscription, Video]):
"""
@ -79,12 +100,13 @@ class VideoProviderManager(object):
:param url:
:return:
"""
for provider in self._configured_providers.values():
try:
provider.validate_subscription_url(url)
return
except InvalidURLError:
pass
for provider in self._registered_providers.values():
if provider.state == VideoProviderState.OK:
try:
provider.validate_subscription_url(url)
return
except InvalidURLError:
pass
raise InvalidURLError("The given URL is not valid for any of the supported sites!")
@ -94,23 +116,16 @@ class VideoProviderManager(object):
:param url:
:return:
"""
for provider in self._configured_providers.values():
try:
provider.validate_subscription_url(url)
# Found the right provider
return provider.fetch_subscription(url)
except InvalidURLError:
pass
for provider in self._registered_providers.values():
if provider.state == VideoProviderState.OK:
try:
provider.validate_subscription_url(url)
# Found the right provider
return provider.fetch_subscription(url)
except InvalidURLError:
pass
raise InvalidURLError("The given URL is not valid for any of the supported sites!")
def get_available_providers(self) -> Iterable[VideoProviderInfo]:
"""
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))
def get_available_providers(self) -> Iterable[VideoProvider]:
return self._registered_providers.values()

View File

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

View File

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

View File

@ -199,4 +199,8 @@ img.muted {
font-size: 3rem;
text-align: center;
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 %}
<div class="row">
{% for provider in providers %}
{% if provider.is_configured %}
<div class="provider-wrapper col-2">
<button class="btn btn-light ">
<img class="provider-logo"
src="{% static provider.image_src %}"
title="{{ provider.name }}">
</button>
</div>
{% endif %}
{% endfor %}
{% if have_unconfigured %}
<div class="provider-wrapper col-2 dropdown">
<button id="btnAddProvider"
class="btn btn-light"
type="button"
data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<span class="provider-logo typcn typcn-plus"
title="Add provider">
</span>
</button>
</div>
{% endif %}
</div>
{% if have_configured %}
<section>
<h3>Configured providers</h3>
<div class="row">
{% for provider in providers %}
{% if 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 }}">
{% if provider.has_error %}
<span class="typcn typcn-warning provider-status text-warning"></span>
{% else %}
<span class="typcn typcn-tick provider-status text-success"></span>
{% endif %}
</button>
</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endif %}
{% 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 %}

View File

@ -18,6 +18,7 @@ from django.conf.urls import include
from django.conf.urls.static import static
from django.urls import path
from YtManagerApp.views.settings.provider_config import ProviderConfigView
from YtManagerApp.views.settings.providers_view import ProvidersView
from .views import first_time
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/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
path('', index, name='home'),
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.views.generic import TemplateView
from YtManagerApp.providers.video_provider import VideoProviderState
from YtManagerApp.services import Services
from collections import namedtuple
VideoProviderInfoViewModel = namedtuple('VideoProviderInfoViewModel',
['id', 'name', 'is_configured', 'image_src'])
['id', 'name', 'is_configured', 'has_error', 'image_src'])
class ProvidersView(LoginRequiredMixin, TemplateView):
@ -16,16 +17,23 @@ class ProvidersView(LoginRequiredMixin, TemplateView):
providers = []
have_unconfigured = False
have_configured = False
for provider in Services.videoProviderManager().get_available_providers():
providers.append(VideoProviderInfoViewModel(
id=provider.id,
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"
))
if not provider.is_configured:
if provider.state != VideoProviderState.NOT_CONFIGURED:
have_configured = True
if provider.state == VideoProviderState.NOT_CONFIGURED:
have_unconfigured = True
context['providers'] = sorted(providers, key=lambda x: x.name)
context['have_unconfigured'] = have_unconfigured
context['have_configured'] = have_configured
return context