diff --git a/app/YtManagerApp/management/video_provider_manager.py b/app/YtManagerApp/management/video_provider_manager.py index e411105..c848310 100644 --- a/app/YtManagerApp/management/video_provider_manager.py +++ b/app/YtManagerApp/management/video_provider_manager.py @@ -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() diff --git a/app/YtManagerApp/providers/dummy_video_provider.py b/app/YtManagerApp/providers/dummy_video_provider.py index 8fc1c12..c3b8670 100644 --- a/app/YtManagerApp/providers/dummy_video_provider.py +++ b/app/YtManagerApp/providers/dummy_video_provider.py @@ -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: diff --git a/app/YtManagerApp/providers/video_provider.py b/app/YtManagerApp/providers/video_provider.py index f377fe4..41a72cc 100644 --- a/app/YtManagerApp/providers/video_provider.py +++ b/app/YtManagerApp/providers/video_provider.py @@ -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: """ diff --git a/app/YtManagerApp/providers/ytapi_video_provider.py b/app/YtManagerApp/providers/ytapi_video_provider.py index 538b394..56a979f 100644 --- a/app/YtManagerApp/providers/ytapi_video_provider.py +++ b/app/YtManagerApp/providers/ytapi_video_provider.py @@ -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 diff --git a/app/YtManagerApp/static/YtManagerApp/css/style.scss b/app/YtManagerApp/static/YtManagerApp/css/style.scss index cfca912..254a50f 100644 --- a/app/YtManagerApp/static/YtManagerApp/css/style.scss +++ b/app/YtManagerApp/static/YtManagerApp/css/style.scss @@ -199,4 +199,8 @@ img.muted { font-size: 3rem; text-align: center; line-height: 4rem; +} + +.provider-status { + font-size: 10rem; } \ No newline at end of file diff --git a/app/YtManagerApp/static/YtManagerApp/js/settings_providers.js b/app/YtManagerApp/static/YtManagerApp/js/settings_providers.js new file mode 100644 index 0000000..4ae5f4a --- /dev/null +++ b/app/YtManagerApp/static/YtManagerApp/js/settings_providers.js @@ -0,0 +1,6 @@ + +jQuery(function() { + + + +}); \ No newline at end of file diff --git a/app/YtManagerApp/templates/YtManagerApp/controls/provider_config_modal.html b/app/YtManagerApp/templates/YtManagerApp/controls/provider_config_modal.html new file mode 100644 index 0000000..b7dd60e --- /dev/null +++ b/app/YtManagerApp/templates/YtManagerApp/controls/provider_config_modal.html @@ -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 %} +
+ {% csrf_token %} + {{ block.super }} +
+{% endblock %} + +{% block modal_body %} + {{ form | crispy }} +{% endblock modal_body %} + +{% block modal_footer %} + + +{% endblock modal_footer %} \ No newline at end of file diff --git a/app/YtManagerApp/templates/YtManagerApp/settings/providers.html b/app/YtManagerApp/templates/YtManagerApp/settings/providers.html index 5489540..4d8189a 100644 --- a/app/YtManagerApp/templates/YtManagerApp/settings/providers.html +++ b/app/YtManagerApp/templates/YtManagerApp/settings/providers.html @@ -3,34 +3,48 @@ {% block body %} -
- - {% for provider in providers %} - {% if provider.is_configured %} -
- -
- {% endif %} - {% endfor %} - - {% if have_unconfigured %} - - {% endif %} - -
+ {% if have_configured %} +
+

Configured providers

+
+ {% for provider in providers %} + {% if provider.is_configured %} +
+ +
+ {% endif %} + {% endfor %} +
+
+ {% endif %} + {% if have_unconfigured %} +

Not configured

+
+ {% for provider in providers %} + {% if not provider.is_configured %} +
+ +
+ {% endif %} + {% endfor %} +
+ {% endif %} {% endblock %} diff --git a/app/YtManagerApp/urls.py b/app/YtManagerApp/urls.py index e275c89..7f33f84 100644 --- a/app/YtManagerApp/urls.py +++ b/app/YtManagerApp/urls.py @@ -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//', UpdateSubscriptionModal.as_view(), name='modal_update_subscription'), path('modal/delete_subscription//', DeleteSubscriptionModal.as_view(), name='modal_delete_subscription'), + path('modal/provider_config//', ProviderConfigView.as_view(), name='modal_provider_config'), + # Pages path('', index, name='home'), path('settings/', SettingsView.as_view(), name='settings'), diff --git a/app/YtManagerApp/views/settings/provider_config.py b/app/YtManagerApp/views/settings/provider_config.py new file mode 100644 index 0000000..621e90e --- /dev/null +++ b/app/YtManagerApp/views/settings/provider_config.py @@ -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) diff --git a/app/YtManagerApp/views/settings/providers_view.py b/app/YtManagerApp/views/settings/providers_view.py index e8cbade..8b66fc3 100644 --- a/app/YtManagerApp/views/settings/providers_view.py +++ b/app/YtManagerApp/views/settings/providers_view.py @@ -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