commit
b1483bbf45
@ -28,7 +28,7 @@ It is a temporary live demo, all data will be deleted after 30 minutes (~30secs
|
||||
- Playlist management
|
||||
- Authentication management
|
||||
- Plays content from flashdrive in offline mode
|
||||
- Plugin system to extend capabilities
|
||||
- Core API & Plugin system to extend capabilities
|
||||
- [Multi Languages](https://github.com/jr-k/obscreen/tree/master/lang)
|
||||
- Cast pictures and iframes to Chromecast
|
||||
- No costly monthly pricing plan per screen or whatever, no cloud, no telemetry
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,6 +1,8 @@
|
||||
jQuery(document).ready(function ($) {
|
||||
const main = function () {
|
||||
|
||||
$('.user-token-reveal').each(function() {
|
||||
updateTokenReveal($(this), false);
|
||||
});
|
||||
};
|
||||
|
||||
$(document).on('click', '.user-add', function () {
|
||||
@ -17,5 +19,26 @@ jQuery(document).ready(function ($) {
|
||||
$('#user-edit-id').val(user.id);
|
||||
});
|
||||
|
||||
const updateTokenReveal = function($btn, revealState) {
|
||||
const $holder = $btn.parents('.user-item:eq(0)');
|
||||
const $input = $holder.find('.input-token:eq(0)');
|
||||
const $icon = $btn.find('i:eq(0)');
|
||||
const isActive = revealState !== undefined ? !revealState : $icon.hasClass('fa-eye-slash');
|
||||
|
||||
if (isActive) {
|
||||
$icon.removeClass('fa-eye-slash').addClass('fa-eye');
|
||||
$btn.removeClass('btn-neutral').addClass('btn-other');
|
||||
$input.val($input.attr('data-private'));
|
||||
} else {
|
||||
$icon.removeClass('fa-eye').addClass('fa-eye-slash');
|
||||
$btn.removeClass('btn-other').addClass('btn-neutral');
|
||||
$input.val($input.attr('data-public'));
|
||||
}
|
||||
};
|
||||
|
||||
$(document).on('click', '.user-token-reveal', function () {
|
||||
updateTokenReveal($(this));
|
||||
});
|
||||
|
||||
main();
|
||||
});
|
||||
@ -22,7 +22,7 @@ jQuery(document).ready(function ($) {
|
||||
$('.modal-variable-edit input:visible:eq(0)').focus().select();
|
||||
$('#variable-edit-name').val(variable.name);
|
||||
$('#variable-edit-description').html(variable.description);
|
||||
$('#variable-edit-description-edition').html(variable.description_edition).toggleClass('hidden', variable.description_edition === '');
|
||||
$('#variable-edit-description-edition').html(variable.description_edition).toggleClass('hidden', variable.description_edition === '' || variable.description_edition === null);
|
||||
$('#variable-edit-value').val(variable.value);
|
||||
$('#variable-edit-id').val(variable.id);
|
||||
});
|
||||
|
||||
@ -15,9 +15,35 @@
|
||||
}
|
||||
|
||||
.tile-tail {
|
||||
a:last-child {
|
||||
.btn {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.btn:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tile-metrics {
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
.widget,
|
||||
.form-group {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
plugins/system/CoreApi/CoreApi.py
Normal file
30
plugins/system/CoreApi/CoreApi.py
Normal file
@ -0,0 +1,30 @@
|
||||
from src.interface.ObPlugin import ObPlugin
|
||||
|
||||
from typing import List, Dict
|
||||
from src.model.entity.Variable import Variable
|
||||
from src.model.enum.HookType import HookType
|
||||
from src.model.hook.HookRegistration import HookRegistration
|
||||
|
||||
|
||||
class CoreApi(ObPlugin):
|
||||
|
||||
def get_version(self) -> str:
|
||||
return '1.0'
|
||||
|
||||
def use_id(self):
|
||||
return 'core_api'
|
||||
|
||||
def use_title(self):
|
||||
return self.translate('plugin_title')
|
||||
|
||||
def use_description(self):
|
||||
return self.translate('plugin_description')
|
||||
|
||||
def use_help_on_activation(self):
|
||||
return self.translate('plugin_help_on_activation')
|
||||
|
||||
def use_variables(self) -> List[Variable]:
|
||||
return []
|
||||
|
||||
def use_hooks_registrations(self) -> List[HookRegistration]:
|
||||
return []
|
||||
375
plugins/system/CoreApi/controller/ContentApiController.py
Normal file
375
plugins/system/CoreApi/controller/ContentApiController.py
Normal file
@ -0,0 +1,375 @@
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
|
||||
from flask import request, abort, jsonify
|
||||
from flask_restx import Resource, Namespace, fields, reqparse
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.utils import secure_filename
|
||||
from src.model.entity.Content import Content
|
||||
from src.manager.FolderManager import FolderManager
|
||||
from src.model.enum.ContentType import ContentType, ContentInputType
|
||||
from src.model.enum.FolderEntity import FolderEntity, FOLDER_ROOT_PATH
|
||||
from src.interface.ObController import ObController
|
||||
from src.util.utils import str_to_enum
|
||||
from plugins.system.CoreApi.exception.ContentPathMissingException import ContentPathMissingException
|
||||
from plugins.system.CoreApi.exception.ContentNotFoundException import ContentNotFoundException
|
||||
from plugins.system.CoreApi.exception.FolderNotFoundException import FolderNotFoundException
|
||||
from plugins.system.CoreApi.exception.FolderNotEmptyException import FolderNotEmptyException
|
||||
from src.service.WebServer import create_require_api_key_decorator
|
||||
|
||||
|
||||
# Namespace for content operations
|
||||
content_ns = Namespace('contents', description='Operations on contents')
|
||||
|
||||
# Output model for content
|
||||
content_output_model = content_ns.model('ContentOutput', {
|
||||
'id': fields.Integer(readOnly=True, description='Unique identifier of the content'),
|
||||
'name': fields.String(description='Name of the content'),
|
||||
'type': fields.String(description='Type of the content'),
|
||||
'location': fields.String(description='Location of the content'),
|
||||
'folder_id': fields.Integer(description='Folder ID where the content is stored')
|
||||
})
|
||||
|
||||
# Model for folder operations
|
||||
folder_model = content_ns.model('Folder', {
|
||||
'name': fields.String(required=True, description='Name of the folder'),
|
||||
'path': fields.String(required=False, description='Path context (with path starting with /)'),
|
||||
'folder_id': fields.Integer(required=False, description='Path context (with folder id)')
|
||||
})
|
||||
|
||||
# Parser for bulk move operations
|
||||
bulk_move_parser = content_ns.parser()
|
||||
bulk_move_parser.add_argument('entity_ids', type=int, action='append', required=True, help='List of content IDs to move')
|
||||
bulk_move_parser.add_argument('path', type=str, required=False, help='Path context (with path starting with /)')
|
||||
bulk_move_parser.add_argument('folder_id', type=int, required=False, help='Path context (with folder id)')
|
||||
|
||||
# Parser for content add/upload (single file)
|
||||
content_upload_parser = content_ns.parser()
|
||||
content_upload_parser.add_argument('name', type=str, required=True, help='Name of the content')
|
||||
content_upload_parser.add_argument('type', type=str, required=True, help='Type of the content')
|
||||
content_upload_parser.add_argument('path', type=str, required=False, help='Path context (with path starting with /)')
|
||||
content_upload_parser.add_argument('folder_id', type=str, required=False, help='Path context (with folder id)')
|
||||
content_upload_parser.add_argument('location', type=str, required=False, help="Content location (valid for types: {}, {} and {})".format(
|
||||
ContentType.URL.value,
|
||||
ContentType.YOUTUBE.value,
|
||||
ContentType.EXTERNAL_STORAGE.value
|
||||
))
|
||||
content_upload_parser.add_argument('object', type=FileStorage, location='files', required=False, help="Content location (valid for types: {} and {})".format(
|
||||
ContentType.PICTURE.value,
|
||||
ContentType.VIDEO.value
|
||||
))
|
||||
|
||||
# Parser for content add/bulk uploads (multiple files)
|
||||
bulk_upload_parser = content_ns.parser()
|
||||
bulk_upload_parser.add_argument('path', type=str, required=False, help='Path context (with path starting with /)')
|
||||
bulk_upload_parser.add_argument('folder_id', type=str, required=False, help='Path context (with folder id)')
|
||||
bulk_upload_parser.add_argument('object', type=FileStorage, location='files', action='append', required=True, help='Files to be uploaded')
|
||||
|
||||
# Parser for content edit
|
||||
content_edit_parser = content_ns.parser()
|
||||
content_edit_parser.add_argument('name', type=str, required=True, help='Name of the content')
|
||||
|
||||
# Parser for content path context actions
|
||||
path_parser = content_ns.parser()
|
||||
path_parser.add_argument('path', type=str, required=False, help='Path context (with path starting with /)')
|
||||
path_parser.add_argument('folder_id', type=str, required=False, help='Path context (with folder id)')
|
||||
|
||||
# Parser for folder add/edit
|
||||
folder_parser = content_ns.parser()
|
||||
folder_parser.add_argument('name', type=str, required=True, help='Name of the folder')
|
||||
folder_parser.add_argument('path', type=str, required=False, help='Path context (with path starting with /)')
|
||||
folder_parser.add_argument('folder_id', type=str, required=False, help='Path context (with folder id)')
|
||||
|
||||
class ContentApiController(ObController):
|
||||
|
||||
def register(self):
|
||||
self.api().add_namespace(content_ns, path='/api/contents')
|
||||
content_ns.add_resource(self.create_resource(ContentListResource), '/')
|
||||
content_ns.add_resource(self.create_resource(ContentResource), '/<int:content_id>')
|
||||
content_ns.add_resource(self.create_resource(ContentLocationResource), '/location/<int:content_id>')
|
||||
content_ns.add_resource(self.create_resource(ContentBulkUploadResource), '/upload-bulk')
|
||||
content_ns.add_resource(self.create_resource(FolderBulkMoveResource), '/folder/move-bulk')
|
||||
content_ns.add_resource(self.create_resource(FolderResource), '/folder')
|
||||
|
||||
def create_resource(self, resource_class):
|
||||
# Function to inject dependencies into resources
|
||||
return type(f'{resource_class.__name__}WithDependencies', (resource_class,), {
|
||||
'_model_store': self._model_store,
|
||||
'_controller': self,
|
||||
'require_api_key': create_require_api_key_decorator(self._web_server)
|
||||
})
|
||||
|
||||
def _get_folder_context(self, data):
|
||||
path = data.get('path', None)
|
||||
folder_id = data.get('folder_id', None)
|
||||
|
||||
if folder_id:
|
||||
folder = self._model_store.folder().get(id=folder_id)
|
||||
|
||||
if not folder:
|
||||
raise FolderNotFoundException()
|
||||
return path, folder
|
||||
|
||||
if not path:
|
||||
raise ContentPathMissingException()
|
||||
|
||||
path = "{}/{}".format(FOLDER_ROOT_PATH, path.strip('/')) if not path.startswith(FOLDER_ROOT_PATH) else path
|
||||
|
||||
folder = self._model_store.folder().get_one_by_path(path=path, entity=FolderEntity.CONTENT)
|
||||
is_root_drive = FolderManager.is_root_drive(path)
|
||||
|
||||
if not folder and not is_root_drive:
|
||||
raise FolderNotFoundException()
|
||||
|
||||
return FOLDER_ROOT_PATH if is_root_drive else path, folder
|
||||
|
||||
def _post_update(self):
|
||||
self._model_store.variable().update_by_name("last_content_update", time.time())
|
||||
|
||||
|
||||
class ContentListResource(Resource):
|
||||
|
||||
@content_ns.expect(path_parser)
|
||||
@content_ns.marshal_list_with(content_output_model)
|
||||
def get(self):
|
||||
"""List all contents"""
|
||||
self.require_api_key()
|
||||
data = path_parser.parse_args()
|
||||
working_folder_path = None
|
||||
working_folder = None
|
||||
folder_id = None
|
||||
|
||||
try:
|
||||
working_folder_path, working_folder = self._controller._get_folder_context(data)
|
||||
folder_id = data.get('folder_id', 0 if not working_folder else working_folder.id)
|
||||
except FolderNotFoundException:
|
||||
pass
|
||||
except ContentPathMissingException:
|
||||
pass
|
||||
|
||||
contents = self._model_store.content().get_contents(
|
||||
folder_id=folder_id,
|
||||
slide_id=data.get('slide_id', None),
|
||||
)
|
||||
result = [content.to_dict() for content in contents]
|
||||
|
||||
return result
|
||||
|
||||
@content_ns.expect(content_upload_parser)
|
||||
@content_ns.marshal_with(content_output_model, code=201)
|
||||
def post(self):
|
||||
"""Add new content"""
|
||||
self.require_api_key()
|
||||
data = content_upload_parser.parse_args()
|
||||
working_folder_path, working_folder = self._controller._get_folder_context(data)
|
||||
location = data.get('location', None)
|
||||
content_type = None
|
||||
|
||||
# Handle content type conversion
|
||||
try:
|
||||
content_type = str_to_enum(data.get('type'), ContentType)
|
||||
except ValueError as e:
|
||||
abort(400, description=str(e))
|
||||
|
||||
# Handle file upload
|
||||
file = data.get('object', None)
|
||||
|
||||
if ContentType.get_input(content_type) == ContentInputType.UPLOAD:
|
||||
if not file:
|
||||
abort(400, description="File is required")
|
||||
|
||||
content = self._model_store.content().add_form_raw(
|
||||
name=data.get('name'),
|
||||
type=content_type,
|
||||
request_files=file,
|
||||
upload_dir=self._controller._app.config['UPLOAD_FOLDER'],
|
||||
location=location,
|
||||
folder_id=working_folder.id if working_folder else None
|
||||
)
|
||||
|
||||
if not content:
|
||||
abort(400, description="Failed to add content")
|
||||
|
||||
return content.to_dict(), 201
|
||||
|
||||
|
||||
class ContentResource(Resource):
|
||||
|
||||
@content_ns.marshal_with(content_output_model)
|
||||
def get(self, content_id: int):
|
||||
"""Get content by ID"""
|
||||
self.require_api_key()
|
||||
content = self._model_store.content().get(content_id)
|
||||
if not content:
|
||||
raise ContentNotFoundException()
|
||||
|
||||
return content.to_dict()
|
||||
|
||||
@content_ns.expect(content_edit_parser)
|
||||
@content_ns.marshal_with(content_output_model)
|
||||
def put(self, content_id: int):
|
||||
"""Update existing content"""
|
||||
self.require_api_key()
|
||||
data = content_edit_parser.parse_args()
|
||||
content = self._model_store.content().get(content_id)
|
||||
|
||||
if not content:
|
||||
raise ContentNotFoundException()
|
||||
|
||||
if 'name' not in data:
|
||||
abort(400, description="Name is required")
|
||||
|
||||
content = self._model_store.content().update_form(
|
||||
id=content.id,
|
||||
name=data.get('name'),
|
||||
)
|
||||
|
||||
self._controller._post_update()
|
||||
|
||||
return content.to_dict()
|
||||
|
||||
def delete(self, content_id: int):
|
||||
"""Delete content"""
|
||||
self.require_api_key()
|
||||
content = self._model_store.content().get(content_id)
|
||||
|
||||
if not content:
|
||||
raise ContentNotFoundException()
|
||||
|
||||
if self._model_store.slide().count_slides_for_content(content.id) > 0:
|
||||
abort(400, description="Content is referenced in slides")
|
||||
|
||||
self._model_store.content().delete(content.id)
|
||||
self._controller._post_update()
|
||||
|
||||
return {'status': 'ok'}, 204
|
||||
|
||||
|
||||
class ContentLocationResource(Resource):
|
||||
|
||||
def get(self, content_id: int):
|
||||
"""Get content location by ID"""
|
||||
self.require_api_key()
|
||||
content = self._model_store.content().get(content_id)
|
||||
|
||||
if not content:
|
||||
raise ContentNotFoundException()
|
||||
|
||||
content_location = self._model_store.content().resolve_content_location(content)
|
||||
|
||||
return {'location': content_location}
|
||||
|
||||
|
||||
class ContentBulkUploadResource(Resource):
|
||||
|
||||
@content_ns.expect(bulk_upload_parser)
|
||||
def post(self):
|
||||
"""Upload multiple content files"""
|
||||
self.require_api_key()
|
||||
data = bulk_upload_parser.parse_args()
|
||||
working_folder_path, working_folder = self._controller._get_folder_context(data)
|
||||
|
||||
for file in data.get('object'):
|
||||
content_type = ContentType.guess_content_type_file(file.filename)
|
||||
name = file.filename.rsplit('.', 1)[0]
|
||||
|
||||
if content_type:
|
||||
self._model_store.content().add_form_raw(
|
||||
name=name,
|
||||
type=content_type,
|
||||
request_files=file,
|
||||
upload_dir=self._controller._app.config['UPLOAD_FOLDER'],
|
||||
folder_id=working_folder.id if working_folder else None
|
||||
)
|
||||
|
||||
return {'status': 'ok'}, 201
|
||||
|
||||
|
||||
class FolderBulkMoveResource(Resource):
|
||||
|
||||
@content_ns.expect(bulk_move_parser)
|
||||
def post(self):
|
||||
"""Move multiple content to another folder"""
|
||||
self.require_api_key()
|
||||
data = bulk_move_parser.parse_args()
|
||||
|
||||
working_folder_path, working_folder = self._controller._get_folder_context(data)
|
||||
|
||||
if 'entity_ids' not in data:
|
||||
abort(400, description="Content IDs are required under 'entity_ids' field")
|
||||
|
||||
entity_ids = data.get('entity_ids')
|
||||
|
||||
for entity_id in entity_ids:
|
||||
self._model_store.folder().move_to_folder(
|
||||
entity_id=entity_id,
|
||||
folder_id=working_folder.id if working_folder else None,
|
||||
entity_is_folder=False,
|
||||
entity=FolderEntity.CONTENT
|
||||
)
|
||||
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
class FolderResource(Resource):
|
||||
|
||||
@content_ns.expect(folder_parser)
|
||||
@content_ns.marshal_with(folder_model, code=201)
|
||||
def post(self):
|
||||
"""Add a new folder"""
|
||||
self.require_api_key()
|
||||
data = folder_parser.parse_args()
|
||||
working_folder_path, working_folder = self._controller._get_folder_context(data)
|
||||
|
||||
if 'name' not in data:
|
||||
abort(400, description="Name is required")
|
||||
|
||||
folder = self._model_store.folder().add_folder(
|
||||
entity=FolderEntity.CONTENT,
|
||||
name=data.get('name'),
|
||||
working_folder_path=working_folder_path
|
||||
)
|
||||
|
||||
return folder.to_dict(), 201
|
||||
|
||||
@content_ns.expect(path_parser)
|
||||
def delete(self):
|
||||
"""Delete a folder"""
|
||||
self.require_api_key()
|
||||
data = path_parser.parse_args()
|
||||
working_folder_path, working_folder = self._controller._get_folder_context(data)
|
||||
|
||||
if not working_folder:
|
||||
abort(400, description="You can't delete this folder")
|
||||
|
||||
content_counter = self._model_store.content().count_contents_for_folder(working_folder.id)
|
||||
folder_counter = self._model_store.folder().count_subfolders_for_folder(working_folder.id)
|
||||
|
||||
if content_counter > 0 or folder_counter:
|
||||
raise FolderNotEmptyException()
|
||||
|
||||
self._model_store.folder().delete(id=working_folder.id)
|
||||
self._controller._post_update()
|
||||
|
||||
return {'status': 'ok'}, 204
|
||||
|
||||
@content_ns.expect(folder_parser)
|
||||
def put(self):
|
||||
"""Update a folder"""
|
||||
self.require_api_key()
|
||||
data = folder_parser.parse_args()
|
||||
working_folder_path, working_folder = self._controller._get_folder_context(data)
|
||||
|
||||
if 'name' not in data:
|
||||
abort(400, description="Name is required")
|
||||
|
||||
if not working_folder:
|
||||
abort(400, description="You can't update this folder")
|
||||
|
||||
self._model_store.folder().rename_folder(
|
||||
folder_id=working_folder.id,
|
||||
name=data.get('name')
|
||||
)
|
||||
|
||||
return {'status': 'ok'}
|
||||
161
plugins/system/CoreApi/controller/PlaylistApiController.py
Normal file
161
plugins/system/CoreApi/controller/PlaylistApiController.py
Normal file
@ -0,0 +1,161 @@
|
||||
from flask import request, abort, jsonify
|
||||
from flask_restx import Resource, Namespace, fields
|
||||
from src.model.entity.Playlist import Playlist
|
||||
from src.interface.ObController import ObController
|
||||
from src.util.utils import str_to_bool
|
||||
from src.service.WebServer import create_require_api_key_decorator
|
||||
|
||||
|
||||
# Namespace for playlists operations
|
||||
playlist_ns = Namespace('playlists', description='Operations on playlist')
|
||||
|
||||
# Output model for a playlist
|
||||
playlist_output_model = playlist_ns.model('PlaylistOutput', {
|
||||
'id': fields.Integer(readOnly=True, description='The unique identifier of a playlist'),
|
||||
'name': fields.String(required=True, description='The playlist name'),
|
||||
'enabled': fields.Boolean(description='Is the playlist enabled?'),
|
||||
'time_sync': fields.Boolean(description='Is time synchronization enabled?')
|
||||
})
|
||||
|
||||
# Parser for playlist attributes (add)
|
||||
playlist_parser = playlist_ns.parser()
|
||||
playlist_parser.add_argument('name', type=str, required=True, help='The playlist name')
|
||||
playlist_parser.add_argument('enabled', type=str_to_bool, default=None, help='Is the playlist enabled?')
|
||||
playlist_parser.add_argument('time_sync', type=str_to_bool, default=None, help='Is time synchronization enabled for slideshow?')
|
||||
|
||||
# Parser for playlist attributes (update)
|
||||
playlist_edit_parser = playlist_parser.copy()
|
||||
playlist_edit_parser.replace_argument('name', type=str, required=False, help='The playlist name')
|
||||
|
||||
|
||||
class PlaylistApiController(ObController):
|
||||
|
||||
def register(self):
|
||||
self.api().add_namespace(playlist_ns, path='/api/playlists')
|
||||
playlist_ns.add_resource(self.create_resource(PlaylistResource), '/<int:playlist_id>')
|
||||
playlist_ns.add_resource(self.create_resource(PlaylistListResource), '/')
|
||||
playlist_ns.add_resource(self.create_resource(PlaylistSlidesResource), '/<int:playlist_id>/slides')
|
||||
playlist_ns.add_resource(self.create_resource(PlaylistNotificationsResource), '/<int:playlist_id>/notifications')
|
||||
|
||||
def create_resource(self, resource_class):
|
||||
# Function to inject dependencies into resources
|
||||
return type(f'{resource_class.__name__}WithDependencies', (resource_class,), {
|
||||
'_model_store': self._model_store,
|
||||
'_controller': self,
|
||||
'require_api_key': create_require_api_key_decorator(self._web_server)
|
||||
})
|
||||
|
||||
|
||||
class PlaylistListResource(Resource):
|
||||
|
||||
@playlist_ns.marshal_list_with(playlist_output_model)
|
||||
def get(self):
|
||||
"""List all playlists"""
|
||||
self.require_api_key()
|
||||
playlists = self._model_store.playlist().get_all(sort="created_at", ascending=True)
|
||||
result = [playlist.to_dict() for playlist in playlists]
|
||||
return result
|
||||
|
||||
@playlist_ns.expect(playlist_parser)
|
||||
@playlist_ns.marshal_with(playlist_output_model, code=201)
|
||||
def post(self):
|
||||
"""Create a new playlist"""
|
||||
self.require_api_key()
|
||||
data = playlist_parser.parse_args()
|
||||
|
||||
if not data.get('name'):
|
||||
abort(400, description="Invalid input")
|
||||
|
||||
playlist = Playlist(
|
||||
name=data.get('name'),
|
||||
enabled=data.get('enabled') if data.get('enabled') is not None else True,
|
||||
time_sync=data.get('time_sync') if data.get('time_sync') is not None else False,
|
||||
)
|
||||
|
||||
try:
|
||||
playlist = self._model_store.playlist().add_form(playlist)
|
||||
except Exception as e:
|
||||
abort(409, description=str(e))
|
||||
|
||||
return playlist.to_dict(), 201
|
||||
|
||||
|
||||
class PlaylistResource(Resource):
|
||||
|
||||
@playlist_ns.marshal_with(playlist_output_model)
|
||||
def get(self, playlist_id):
|
||||
"""Get a playlist by its ID"""
|
||||
self.require_api_key()
|
||||
playlist = self._model_store.playlist().get(playlist_id)
|
||||
if not playlist:
|
||||
abort(404, description="Playlist not found")
|
||||
return playlist.to_dict()
|
||||
|
||||
@playlist_ns.expect(playlist_edit_parser)
|
||||
@playlist_ns.marshal_with(playlist_output_model)
|
||||
def put(self, playlist_id):
|
||||
"""Update an existing playlist"""
|
||||
self.require_api_key()
|
||||
data = playlist_edit_parser.parse_args()
|
||||
|
||||
playlist = self._model_store.playlist().get(playlist_id)
|
||||
|
||||
if not playlist:
|
||||
abort(404, description="Playlist not found")
|
||||
|
||||
self._model_store.playlist().update_form(
|
||||
id=playlist_id,
|
||||
name=data.get('name', playlist.name),
|
||||
time_sync=data.get('time_sync', playlist.time_sync),
|
||||
enabled=data.get('enabled', playlist.enabled)
|
||||
)
|
||||
updated_playlist = self._model_store.playlist().get(playlist_id)
|
||||
return updated_playlist.to_dict()
|
||||
|
||||
def delete(self, playlist_id):
|
||||
"""Delete a playlist"""
|
||||
self.require_api_key()
|
||||
playlist = self._model_store.playlist().get(playlist_id)
|
||||
if not playlist:
|
||||
abort(404, description="Playlist not found")
|
||||
|
||||
if self._model_store.slide().count_slides_for_playlist(playlist_id) > 0:
|
||||
abort(400, description="Playlist cannot be deleted because it has slides")
|
||||
|
||||
if self._model_store.node_player_group().count_node_player_groups_for_playlist(playlist_id) > 0:
|
||||
abort(400, description="Playlist cannot be deleted because it is associated with node player groups")
|
||||
|
||||
self._model_store.playlist().delete(playlist_id)
|
||||
return '', 204
|
||||
|
||||
|
||||
class PlaylistSlidesResource(Resource):
|
||||
|
||||
def get(self, playlist_id):
|
||||
"""Get slides associated with a playlist"""
|
||||
self.require_api_key()
|
||||
playlist = self._model_store.playlist().get(playlist_id)
|
||||
|
||||
if not playlist:
|
||||
abort(404, description="Playlist not found")
|
||||
|
||||
slides = self._model_store.slide().get_slides(is_notification=False, playlist_id=playlist_id)
|
||||
|
||||
result = [slide.to_dict() for slide in slides]
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
class PlaylistNotificationsResource(Resource):
|
||||
|
||||
def get(self, playlist_id):
|
||||
"""Get notifications associated with a playlist"""
|
||||
self.require_api_key()
|
||||
playlist = self._model_store.playlist().get(playlist_id)
|
||||
|
||||
if not playlist:
|
||||
abort(404, description="Playlist not found")
|
||||
|
||||
slides = self._model_store.slide().get_slides(is_notification=True, playlist_id=playlist_id)
|
||||
|
||||
result = [slide.to_dict() for slide in slides]
|
||||
return jsonify(result)
|
||||
322
plugins/system/CoreApi/controller/SlideApiController.py
Normal file
322
plugins/system/CoreApi/controller/SlideApiController.py
Normal file
@ -0,0 +1,322 @@
|
||||
import time
|
||||
|
||||
from flask import request, abort, jsonify
|
||||
from flask_restx import Resource, Namespace, fields
|
||||
from src.model.entity.Slide import Slide
|
||||
from src.interface.ObController import ObController
|
||||
from src.util.utils import str_datetime_to_cron, str_weekdaytime_to_cron, str_to_bool
|
||||
from src.service.WebServer import create_require_api_key_decorator
|
||||
|
||||
# Namespace for slide operations
|
||||
slide_ns = Namespace('slides', description='Operations on slides')
|
||||
|
||||
# Output model for a slide
|
||||
slide_output_model = slide_ns.model('SlideOutput', {
|
||||
'id': fields.Integer(readOnly=True, description='The unique identifier of a slide'),
|
||||
'content_id': fields.Integer(description='The content ID for the slide'),
|
||||
'playlist_id': fields.Integer(description='The playlist ID to which the slide belongs'),
|
||||
'enabled': fields.Boolean(description='Is the slide enabled?'),
|
||||
'delegate_duration': fields.Boolean(description='Should the duration be delegated?'),
|
||||
'duration': fields.Integer(description='Duration of the slide'),
|
||||
'position': fields.Integer(description='Position of the slide'),
|
||||
'is_notification': fields.Boolean(description='Is the slide a notification?'),
|
||||
'cron_schedule': fields.String(description='Cron expression for scheduling start'),
|
||||
'cron_schedule_end': fields.String(description='Cron expression for scheduling end'),
|
||||
})
|
||||
|
||||
# Input model for updating slide positions
|
||||
positions_model = slide_ns.model('SlidePositions', {
|
||||
'positions': fields.Raw(required=True, description='A dictionary where keys are slide IDs and values are their new positions')
|
||||
})
|
||||
|
||||
# Parser for basic slide attributes
|
||||
slide_base_parser = slide_ns.parser()
|
||||
slide_base_parser.add_argument('content_id', type=int, required=True, help='The content ID for the slide')
|
||||
slide_base_parser.add_argument('playlist_id', type=int, required=True, help='The playlist ID to which the slide belongs')
|
||||
slide_base_parser.add_argument('enabled', type=str_to_bool, default=None, help='Is the slide enabled?')
|
||||
slide_base_parser.add_argument('duration', type=int, default=3, help='Duration of the slide')
|
||||
slide_base_parser.add_argument('position', type=int, default=999, help='Position of the slide')
|
||||
|
||||
# Parser for slide attributes (add)
|
||||
slide_parser = slide_base_parser.copy()
|
||||
slide_parser.add_argument('scheduling', type=str, required=True, help='Scheduling type: loop, datetime or inweek')
|
||||
slide_parser.add_argument('delegate_duration', type=str_to_bool, default=None, help='Should the duration be delegated to video\'s duration?')
|
||||
slide_parser.add_argument('datetime_start', type=str, required=False, help='Start datetime for scheduling (format: Y-m-d H:M)')
|
||||
slide_parser.add_argument('datetime_end', type=str, required=False, help='End datetime for scheduling (format: Y-m-d H:M)')
|
||||
slide_parser.add_argument('day_start', type=int, required=False, help='Start day for inweek scheduling (format: 1 for Monday to 7 for Sunday)')
|
||||
slide_parser.add_argument('time_start', type=str, required=False, help='Start time for inweek scheduling (format: H:M)')
|
||||
slide_parser.add_argument('day_end', type=int, required=False, help='End day for inweek scheduling (format: 1 for Monday to 7 for Sunday)')
|
||||
slide_parser.add_argument('time_end', type=str, required=False, help='End time for inweek scheduling (format: H:M)')
|
||||
|
||||
# Parser for slide notification attributes (add)
|
||||
slide_notification_parser = slide_base_parser.copy()
|
||||
slide_notification_parser.add_argument('scheduling', type=str, required=True, help='Scheduling type: datetime or cron')
|
||||
slide_notification_parser.add_argument('datetime_start', type=str, required=False, help='Start datetime for notification scheduling (format: Y-m-d H:M)')
|
||||
slide_notification_parser.add_argument('datetime_end', type=str, required=False, help='End datetime for notification scheduling (format: Y-m-d H:M)')
|
||||
slide_notification_parser.add_argument('cron_start', type=str, required=False, help='Cron expression for notification scheduling start (format: * * * * * * *)')
|
||||
slide_notification_parser.add_argument('cron_end', type=str, required=False, help='Cron expression for notification scheduling end (format: * * * * * * *)')
|
||||
|
||||
# Parser for slide attributes (update)
|
||||
slide_edit_parser = slide_parser.copy()
|
||||
slide_edit_parser.replace_argument('scheduling', type=str, required=False, help='Scheduling type: loop, datetime, or inweek')
|
||||
slide_edit_parser.replace_argument('content_id', type=int, required=False, help='The content ID for the slide')
|
||||
slide_edit_parser.replace_argument('playlist_id', type=int, required=False, help='The playlist ID to which the slide belongs')
|
||||
|
||||
# Parser for slide notification attributes (update)
|
||||
slide_notification_edit_parser = slide_notification_parser.copy()
|
||||
slide_notification_edit_parser.replace_argument('scheduling', type=str, required=False, help='Scheduling type: datetime or cron')
|
||||
slide_notification_edit_parser.replace_argument('content_id', type=int, required=False, help='The content ID for the slide')
|
||||
slide_notification_edit_parser.replace_argument('playlist_id', type=int, required=False, help='The playlist ID to which the slide belongs')
|
||||
|
||||
|
||||
class SlideApiController(ObController):
|
||||
|
||||
def register(self):
|
||||
self.api().add_namespace(slide_ns, path='/api/slides')
|
||||
slide_ns.add_resource(self.create_resource(SlideNotificationResource), '/notifications/<int:slide_id>')
|
||||
slide_ns.add_resource(self.create_resource(SlideResource), '/<int:slide_id>')
|
||||
slide_ns.add_resource(self.create_resource(SlideAddResource), '/')
|
||||
slide_ns.add_resource(self.create_resource(SlideAddNotificationResource), '/notifications')
|
||||
slide_ns.add_resource(self.create_resource(SlidePositionResource), '/positions')
|
||||
|
||||
def create_resource(self, resource_class):
|
||||
# Function to inject dependencies into resources
|
||||
return type(f'{resource_class.__name__}WithDependencies', (resource_class,), {
|
||||
'_model_store': self._model_store,
|
||||
'_controller': self,
|
||||
'require_api_key': create_require_api_key_decorator(self._web_server)
|
||||
})
|
||||
|
||||
def _add_slide_or_notification(self, data, is_notification=False):
|
||||
if not data or 'content_id' not in data:
|
||||
abort(400, description="Valid Content ID is required")
|
||||
|
||||
if not self._model_store.content().get(data.get('content_id')):
|
||||
abort(404, description="Content not found")
|
||||
|
||||
if not data or 'playlist_id' not in data:
|
||||
abort(400, description="Valid Playlist ID is required")
|
||||
|
||||
if not self._model_store.playlist().get(data.get('playlist_id')):
|
||||
abort(404, description="Playlist not found")
|
||||
|
||||
cron_schedule_start, cron_schedule_end = self._resolve_scheduling(data, is_notification=is_notification)
|
||||
|
||||
slide = Slide(
|
||||
content_id=data.get('content_id'),
|
||||
enabled=data.get('enabled') if data.get('enabled') is not None else True,
|
||||
delegate_duration=data.get('delegate_duration') if data.get('delegate_duration') is not None else False,
|
||||
duration=data.get('duration', 3),
|
||||
position=data.get('position', 999),
|
||||
is_notification=is_notification,
|
||||
playlist_id=data.get('playlist_id', None),
|
||||
cron_schedule=cron_schedule_start,
|
||||
cron_schedule_end=cron_schedule_end
|
||||
)
|
||||
|
||||
slide = self._model_store.slide().add_form(slide)
|
||||
self._post_update()
|
||||
|
||||
return slide.to_dict(), 201
|
||||
|
||||
def _resolve_scheduling(self, data, is_notification=False):
|
||||
try:
|
||||
return self._resolve_scheduling_for_notification(data) if is_notification else self._resolve_scheduling_for_slide(data)
|
||||
except ValueError as ve:
|
||||
abort(400, description=str(ve))
|
||||
|
||||
def _resolve_scheduling_for_slide(self, data):
|
||||
scheduling = data.get('scheduling', 'loop')
|
||||
cron_schedule_start = None
|
||||
cron_schedule_end = None
|
||||
|
||||
if scheduling == 'loop':
|
||||
pass
|
||||
elif scheduling == 'datetime':
|
||||
datetime_start = data.get('datetime_start')
|
||||
datetime_end = data.get('datetime_end')
|
||||
|
||||
if not datetime_start:
|
||||
abort(400, description="Field datetime_start is required for scheduling='datetime'")
|
||||
|
||||
cron_schedule_start = str_datetime_to_cron(datetime_str=datetime_start)
|
||||
|
||||
if datetime_end:
|
||||
cron_schedule_end = str_datetime_to_cron(datetime_str=datetime_end)
|
||||
elif scheduling == 'inweek':
|
||||
day_start = data.get('day_start')
|
||||
time_start = data.get('time_start')
|
||||
day_end = data.get('day_end')
|
||||
time_end = data.get('time_end')
|
||||
|
||||
if not (day_start and time_start and day_end and time_end):
|
||||
abort(400, description="day_start, time_start, day_end, and time_end are required for scheduling='inweek'")
|
||||
cron_schedule_start = str_weekdaytime_to_cron(weekday=int(day_start), time_str=time_start)
|
||||
cron_schedule_end = str_weekdaytime_to_cron(weekday=int(day_end), time_str=time_end)
|
||||
else:
|
||||
abort(400, description="Invalid value for slide scheduling. Expected 'loop', 'datetime', or 'inweek'.")
|
||||
|
||||
return cron_schedule_start, cron_schedule_end
|
||||
|
||||
def _resolve_scheduling_for_notification(self, data):
|
||||
scheduling = data.get('scheduling', 'datetime')
|
||||
cron_schedule_start = None
|
||||
cron_schedule_end = None
|
||||
|
||||
if scheduling == 'datetime':
|
||||
datetime_start = data.get('datetime_start')
|
||||
datetime_end = data.get('datetime_end')
|
||||
|
||||
if not datetime_start:
|
||||
abort(400, description="Field datetime_start is required for scheduling='datetime'")
|
||||
|
||||
cron_schedule_start = str_datetime_to_cron(datetime_str=datetime_start)
|
||||
|
||||
if datetime_end:
|
||||
cron_schedule_end = str_datetime_to_cron(datetime_str=datetime_end)
|
||||
elif scheduling == 'cron':
|
||||
cron_schedule_start = data.get('cron_start')
|
||||
|
||||
if not cron_schedule_start:
|
||||
abort(400, description="Field cron_start is required for scheduling='cron'")
|
||||
else:
|
||||
abort(400, description="Invalid value for notification scheduling. Expected 'datetime' or 'cron'.")
|
||||
|
||||
return cron_schedule_start, cron_schedule_end
|
||||
|
||||
def _post_update(self):
|
||||
self._model_store.variable().update_by_name("last_slide_update", time.time())
|
||||
|
||||
|
||||
class SlideAddResource(Resource):
|
||||
|
||||
@slide_ns.expect(slide_parser)
|
||||
@slide_ns.marshal_with(slide_output_model, code=201)
|
||||
def post(self):
|
||||
"""Add a new slide"""
|
||||
self.require_api_key()
|
||||
data = slide_parser.parse_args()
|
||||
return self._controller._add_slide_or_notification(data, is_notification=False)
|
||||
|
||||
|
||||
class SlideAddNotificationResource(Resource):
|
||||
|
||||
@slide_ns.expect(slide_notification_parser)
|
||||
@slide_ns.marshal_with(slide_output_model, code=201)
|
||||
def post(self):
|
||||
"""Add a new slide notification"""
|
||||
self.require_api_key()
|
||||
data = slide_notification_parser.parse_args()
|
||||
return self._controller._add_slide_or_notification(data, is_notification=True)
|
||||
|
||||
|
||||
class SlideResource(Resource):
|
||||
|
||||
@slide_ns.marshal_with(slide_output_model)
|
||||
def get(self, slide_id):
|
||||
"""Get a slide by its ID"""
|
||||
self.require_api_key()
|
||||
slide = self._model_store.slide().get(slide_id)
|
||||
if not slide:
|
||||
abort(404, description="Slide not found")
|
||||
return slide.to_dict()
|
||||
|
||||
@slide_ns.expect(slide_edit_parser)
|
||||
@slide_ns.marshal_with(slide_output_model)
|
||||
def put(self, slide_id):
|
||||
"""Edit an existing slide"""
|
||||
self.require_api_key()
|
||||
data = slide_edit_parser.parse_args()
|
||||
|
||||
slide = self._model_store.slide().get(slide_id)
|
||||
if not slide:
|
||||
abort(404, description="Slide not found")
|
||||
|
||||
cron_schedule_start = slide.cron_schedule
|
||||
cron_schedule_end = slide.cron_schedule_end
|
||||
|
||||
if data.get('scheduling'):
|
||||
cron_schedule_start, cron_schedule_end = self._controller._resolve_scheduling(data, is_notification=slide.is_notification)
|
||||
|
||||
self._model_store.slide().update_form(
|
||||
id=slide_id,
|
||||
content_id=data.get('content_id', slide.content_id),
|
||||
enabled=data.get('enabled', slide.enabled),
|
||||
position=data.get('position', slide.position),
|
||||
duration=data.get('duration', slide.duration),
|
||||
cron_schedule=cron_schedule_start,
|
||||
cron_schedule_end=cron_schedule_end
|
||||
)
|
||||
self._controller._post_update()
|
||||
|
||||
updated_slide = self._model_store.slide().get(slide_id)
|
||||
return updated_slide.to_dict()
|
||||
|
||||
def delete(self, slide_id):
|
||||
"""Delete a slide"""
|
||||
self.require_api_key()
|
||||
slide = self._model_store.slide().get(slide_id)
|
||||
|
||||
if not slide:
|
||||
abort(404, description="Slide not found")
|
||||
|
||||
self._model_store.slide().delete(slide_id)
|
||||
self._controller._post_update()
|
||||
|
||||
return '', 204
|
||||
|
||||
|
||||
class SlideNotificationResource(Resource):
|
||||
|
||||
@slide_ns.expect(slide_notification_edit_parser)
|
||||
@slide_ns.marshal_with(slide_output_model)
|
||||
def put(self, slide_id):
|
||||
"""Edit an existing slide notification"""
|
||||
self.require_api_key()
|
||||
data = slide_notification_edit_parser.parse_args()
|
||||
|
||||
slide = self._model_store.slide().get(slide_id)
|
||||
if not slide:
|
||||
abort(404, description="Slide not found")
|
||||
|
||||
cron_schedule_start = slide.cron_schedule
|
||||
cron_schedule_end = slide.cron_schedule_end
|
||||
|
||||
if data.get('scheduling'):
|
||||
cron_schedule_start, cron_schedule_end = self._controller._resolve_scheduling(data, is_notification=slide.is_notification)
|
||||
|
||||
self._model_store.slide().update_form(
|
||||
id=slide_id,
|
||||
content_id=data.get('content_id', slide.content_id),
|
||||
enabled=data.get('enabled', slide.enabled),
|
||||
position=data.get('position', slide.position),
|
||||
delegate_duration=data.get('delegate_duration', slide.delegate_duration),
|
||||
duration=data.get('duration', slide.duration),
|
||||
cron_schedule=cron_schedule_start,
|
||||
cron_schedule_end=cron_schedule_end
|
||||
)
|
||||
self._controller._post_update()
|
||||
|
||||
updated_slide = self._model_store.slide().get(slide_id)
|
||||
return updated_slide.to_dict()
|
||||
|
||||
|
||||
class SlidePositionResource(Resource):
|
||||
|
||||
@slide_ns.expect(positions_model)
|
||||
def post(self):
|
||||
"""Update positions of multiple slides"""
|
||||
self.require_api_key()
|
||||
data = request.get_json()
|
||||
positions = data.get('positions', None) if data else None
|
||||
|
||||
if not positions:
|
||||
abort(400, description="Positions data are required")
|
||||
|
||||
# Ensure the input is a dictionary with integer keys and values
|
||||
if not isinstance(data, dict) or not all(isinstance(k, str) and isinstance(v, int) for k, v in positions.items()):
|
||||
abort(400, description="Input must be a dictionary with string keys as slide IDs and integer values as positions")
|
||||
|
||||
self._model_store.slide().update_positions(positions)
|
||||
self._controller._post_update()
|
||||
return jsonify({'status': 'ok'})
|
||||
@ -0,0 +1,6 @@
|
||||
from src.exceptions.HttpClientException import HttpClientException
|
||||
|
||||
|
||||
class ContentNotFoundException(HttpClientException):
|
||||
code = 404
|
||||
description = "Content not found"
|
||||
@ -0,0 +1,6 @@
|
||||
from src.exceptions.HttpClientException import HttpClientException
|
||||
|
||||
|
||||
class ContentPathMissingException(HttpClientException):
|
||||
code = 400
|
||||
description = "Path is required"
|
||||
@ -0,0 +1,6 @@
|
||||
from src.exceptions.HttpClientException import HttpClientException
|
||||
|
||||
|
||||
class FolderNotEmptyException(HttpClientException):
|
||||
code = 400
|
||||
description = "Folder is not empty"
|
||||
@ -0,0 +1,6 @@
|
||||
from src.exceptions.HttpClientException import HttpClientException
|
||||
|
||||
|
||||
class FolderNotFoundException(HttpClientException):
|
||||
code = 404
|
||||
description = "Folder not found"
|
||||
5
plugins/system/CoreApi/lang/en.json
Normal file
5
plugins/system/CoreApi/lang/en.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugin_title": "Core API",
|
||||
"plugin_description": "Adds api feature wrapping core features",
|
||||
"plugin_help_on_activation": "Documentation will be available on the /api page"
|
||||
}
|
||||
5
plugins/system/CoreApi/lang/es.json
Normal file
5
plugins/system/CoreApi/lang/es.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugin_title": "Core API",
|
||||
"plugin_description": "Agrega características de API que envuelven las características principales",
|
||||
"plugin_help_on_activation": "La documentación estará disponible en la página /api"
|
||||
}
|
||||
5
plugins/system/CoreApi/lang/fr.json
Normal file
5
plugins/system/CoreApi/lang/fr.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugin_title": "Core API",
|
||||
"plugin_description": "Ajoute des fonctionnalités d'API englobant les fonctionnalités principales",
|
||||
"plugin_help_on_activation": "La documentation sera disponible sur la page /api"
|
||||
}
|
||||
5
plugins/system/CoreApi/lang/it.json
Normal file
5
plugins/system/CoreApi/lang/it.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugin_title": "Core API",
|
||||
"plugin_description": "Aggiunge funzionalità API che racchiudono le funzionalità di base",
|
||||
"plugin_help_on_activation": "La documentazione sarà disponibile nella pagina /api"
|
||||
}
|
||||
@ -10,8 +10,11 @@ from src.util.utils import am_i_in_docker
|
||||
|
||||
class GitUpdater(ObPlugin):
|
||||
|
||||
def get_version(self) -> str:
|
||||
return '1.0'
|
||||
|
||||
def use_id(self):
|
||||
return 'git_updater'
|
||||
return 'core_updater'
|
||||
|
||||
def use_title(self):
|
||||
return self.translate('plugin_title')
|
||||
@ -19,6 +22,9 @@ class GitUpdater(ObPlugin):
|
||||
def use_description(self):
|
||||
return self.translate('plugin_description')
|
||||
|
||||
def use_help_on_activation(self):
|
||||
return None
|
||||
|
||||
def use_variables(self) -> List[Variable]:
|
||||
return []
|
||||
|
||||
@ -10,10 +10,10 @@ from src.util.utils import run_system_command, sudo_run_system_command, get_work
|
||||
from src.Application import Application
|
||||
|
||||
|
||||
class GitUpdaterController(ObController):
|
||||
class CoreUpdaterController(ObController):
|
||||
|
||||
def register(self):
|
||||
self._app.add_url_rule('/git-updater/update/now', 'git_updater_update_now', self._auth(self.update_now), methods=['GET'])
|
||||
self._app.add_url_rule('/core-updater/update/now', 'core_updater_update_now', self._auth(self.update_now), methods=['GET'])
|
||||
|
||||
def update_now(self):
|
||||
debug = self._model_store.config().map().get('debug')
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"plugin_title": "Git Updater Button",
|
||||
"plugin_title": "Core Updater Button",
|
||||
"plugin_description": "Adds an update button (only for system-wide installations)",
|
||||
"button_update": "Update"
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"plugin_title": "Botón de Actualización de Git",
|
||||
"plugin_title": "Core Updater Button",
|
||||
"plugin_description": "Añade un botón de actualización (solo para instalaciones a nivel del sistema)",
|
||||
"button_update": "Actualizar"
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"plugin_title": "Bouton de mise à jour",
|
||||
"plugin_title": "Core Updater Button",
|
||||
"plugin_description": "Ajoute un bouton de mise à jour (seulement pour les installations système)",
|
||||
"button_update": "Mettre à jour"
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"plugin_title": "Pulsante di aggiornamento",
|
||||
"plugin_title": "Core Updater Button",
|
||||
"plugin_description": "Aggiunge un pulsante di aggiornamento (solo per installazioni di sistema)",
|
||||
"button_update": "Aggiorna"
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
{% if not am_i_in_docker() %}
|
||||
<a href="{{ url_for('core_updater_update_now') }}" class="btn sysinfo-update protected"><i class="fa fa-cloud-arrow-down icon-left"></i>{{ l.core_updater_button_update }}</a>
|
||||
{% endif %}
|
||||
|
||||
@ -1,4 +0,0 @@
|
||||
{% if not am_i_in_docker() %}
|
||||
<a href="{{ url_for('git_updater_update_now') }}" class="btn sysinfo-update protected"><i class="fa fa-cloud-arrow-down icon-left"></i>{{ l.git_updater_button_update }}</a>
|
||||
{% endif %}
|
||||
|
||||
@ -8,6 +8,9 @@ from src.model.hook.HookRegistration import HookRegistration
|
||||
|
||||
class Dashboard(ObPlugin):
|
||||
|
||||
def get_version(self) -> str:
|
||||
return '1.0'
|
||||
|
||||
def use_id(self):
|
||||
return 'dashboard'
|
||||
|
||||
@ -17,6 +20,9 @@ class Dashboard(ObPlugin):
|
||||
def use_description(self):
|
||||
return self.translate('plugin_description')
|
||||
|
||||
def use_help_on_activation(self):
|
||||
return None
|
||||
|
||||
def use_variables(self) -> List[Variable]:
|
||||
return []
|
||||
|
||||
|
||||
@ -5,3 +5,4 @@ waitress
|
||||
flask-login
|
||||
pysqlite3
|
||||
psutil
|
||||
flask-restx
|
||||
|
||||
@ -69,6 +69,7 @@ class AuthController(ObController):
|
||||
'auth/list.jinja.html',
|
||||
error=request.args.get('error', None),
|
||||
users=self._model_store.user().get_users(exclude=User.DEFAULT_USER if demo else None),
|
||||
plugin_core_api_enabled=self._model_store.variable().map().get('plugin_core_api_enabled').as_bool()
|
||||
)
|
||||
|
||||
def auth_user_add(self):
|
||||
|
||||
@ -32,7 +32,7 @@ class ContentController(ObController):
|
||||
self._app.add_url_rule('/slideshow/content/upload-bulk', 'slideshow_content_upload_bulk', self._auth(self.slideshow_content_upload_bulk), methods=['POST'])
|
||||
self._app.add_url_rule('/slideshow/content/delete-bulk-explr', 'slideshow_content_delete_bulk_explr', self._auth(self.slideshow_content_delete_bulk_explr), methods=['GET'])
|
||||
|
||||
def get_working_folder(self):
|
||||
def get_folder_context(self):
|
||||
working_folder_path = request.args.get('path', None)
|
||||
working_folder = None
|
||||
|
||||
@ -47,7 +47,7 @@ class ContentController(ObController):
|
||||
|
||||
def slideshow_content_list(self):
|
||||
self._model_store.variable().update_by_name('last_pillmenu_slideshow', 'slideshow_content_list')
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
slides_with_content = self._model_store.slide().get_all_indexed(attribute='content_id', multiple=True)
|
||||
|
||||
return render_template(
|
||||
@ -64,7 +64,7 @@ class ContentController(ObController):
|
||||
)
|
||||
|
||||
def slideshow_content_add(self):
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
route_args = {
|
||||
"path": working_folder_path,
|
||||
}
|
||||
@ -86,7 +86,7 @@ class ContentController(ObController):
|
||||
return redirect(url_for('slideshow_content_list', **route_args))
|
||||
|
||||
def slideshow_content_upload_bulk(self):
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
|
||||
for key in request.files:
|
||||
files = request.files.getlist(key)
|
||||
@ -111,7 +111,7 @@ class ContentController(ObController):
|
||||
if not content:
|
||||
return abort(404)
|
||||
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
|
||||
return render_template(
|
||||
'slideshow/contents/edit.jinja.html',
|
||||
@ -123,7 +123,7 @@ class ContentController(ObController):
|
||||
)
|
||||
|
||||
def slideshow_content_save(self, content_id: int = 0):
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
content = self._model_store.content().get(content_id)
|
||||
|
||||
if not content:
|
||||
@ -139,7 +139,7 @@ class ContentController(ObController):
|
||||
return redirect(url_for('slideshow_content_edit', content_id=content_id, saved=1))
|
||||
|
||||
def slideshow_content_delete(self):
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
error_tuple = self.delete_content_by_id(request.args.get('id'))
|
||||
route_args = {
|
||||
"path": working_folder_path,
|
||||
@ -151,7 +151,7 @@ class ContentController(ObController):
|
||||
return redirect(url_for('slideshow_content_list', **route_args))
|
||||
|
||||
def slideshow_content_rename(self):
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
self._model_store.content().update_form(
|
||||
id=request.form['id'],
|
||||
name=request.form['name'],
|
||||
@ -182,7 +182,7 @@ class ContentController(ObController):
|
||||
return redirect(url_for('slideshow_content_list', path=path))
|
||||
|
||||
def slideshow_content_folder_add(self):
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
|
||||
self._model_store.folder().add_folder(
|
||||
entity=FolderEntity.CONTENT,
|
||||
@ -193,7 +193,7 @@ class ContentController(ObController):
|
||||
return redirect(url_for('slideshow_content_list', path=working_folder_path))
|
||||
|
||||
def slideshow_content_folder_rename(self):
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
self._model_store.folder().rename_folder(
|
||||
folder_id=request.form['id'],
|
||||
name=request.form['name'],
|
||||
@ -202,7 +202,7 @@ class ContentController(ObController):
|
||||
return redirect(url_for('slideshow_content_list', path=working_folder_path))
|
||||
|
||||
def slideshow_content_folder_move(self):
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
entity_ids = request.form['entity_ids'].split(',')
|
||||
folder_ids = request.form['folder_ids'].split(',')
|
||||
|
||||
@ -225,7 +225,7 @@ class ContentController(ObController):
|
||||
return redirect(url_for('slideshow_content_list', path=working_folder_path))
|
||||
|
||||
def slideshow_content_folder_delete(self):
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
error_tuple = self.delete_folder_by_id(request.args.get('id'))
|
||||
route_args = {
|
||||
"path": working_folder_path,
|
||||
@ -245,7 +245,7 @@ class ContentController(ObController):
|
||||
return redirect(self._model_store.content().resolve_content_location(content))
|
||||
|
||||
def slideshow_content_delete_bulk_explr(self):
|
||||
working_folder_path, working_folder = self.get_working_folder()
|
||||
working_folder_path, working_folder = self.get_folder_context()
|
||||
entity_ids = request.args.get('entity_ids', '').split(',')
|
||||
folder_ids = request.args.get('folder_ids', '').split(',')
|
||||
route_args_dict = {"path": working_folder_path}
|
||||
|
||||
5
src/exceptions/HttpClientException.py
Normal file
5
src/exceptions/HttpClientException.py
Normal file
@ -0,0 +1,5 @@
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
|
||||
class HttpClientException(HTTPException):
|
||||
pass
|
||||
@ -48,3 +48,6 @@ class ObController(abc.ABC):
|
||||
|
||||
def render_view(self, template_file: str, **parameters: dict) -> str:
|
||||
return self._template_renderer.render_view(template_file, self.plugin(), **parameters)
|
||||
|
||||
def api(self):
|
||||
return self._web_server.api
|
||||
|
||||
@ -38,6 +38,10 @@ class ObPlugin(abc.ABC):
|
||||
def use_description(self) -> str:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def use_help_on_activation(self) -> Optional[str]:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def use_variables(self) -> List[Variable]:
|
||||
pass
|
||||
@ -46,6 +50,10 @@ class ObPlugin(abc.ABC):
|
||||
def use_hooks_registrations(self) -> List[HookRegistration]:
|
||||
pass
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_version(self) -> str:
|
||||
pass
|
||||
|
||||
def get_directory(self) -> Optional[str]:
|
||||
return self._plugin_dir
|
||||
|
||||
@ -82,7 +90,10 @@ class ObPlugin(abc.ABC):
|
||||
def add_functional_hook_registration(self, hook: HookType, priority: int = 0, function=None) -> FunctionalHookRegistration:
|
||||
return FunctionalHookRegistration(plugin=self, hook=hook, priority=priority, function=function)
|
||||
|
||||
def translate(self, token, resolve=False) -> Union[Dict, str]:
|
||||
def translate(self, token, resolve=True) -> Union[Dict, str]:
|
||||
if not token:
|
||||
token = '<UNKNOWN>'
|
||||
|
||||
token = token if token.startswith(self.use_id()) else "{}_{}".format(self.use_id(), token)
|
||||
return self._model_store.lang().translate(token) if resolve else token
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ load_dotenv()
|
||||
|
||||
class ConfigManager:
|
||||
|
||||
APPLICATION_NAME = "Obscreen"
|
||||
DEFAULT_PORT = 5000
|
||||
DEFAULT_PORT_HTTP_EXTERNAL_STORAGE = 5001
|
||||
VERSION_FILE = 'version.txt'
|
||||
@ -16,6 +17,7 @@ class ConfigManager:
|
||||
def __init__(self, replacers: Dict):
|
||||
self._replacers = replacers
|
||||
self._CONFIG = {
|
||||
'application_name': self.APPLICATION_NAME,
|
||||
'version': None,
|
||||
'demo': False,
|
||||
'port_http_external_storage': self.DEFAULT_PORT_HTTP_EXTERNAL_STORAGE,
|
||||
@ -87,7 +89,7 @@ class ConfigManager:
|
||||
if args.log_stdout:
|
||||
self._CONFIG['log_stdout'] = args.log_stdout
|
||||
if args.version:
|
||||
print("Obscreen version v{} (https://github.com/jr-k/obscreen)".format(self._CONFIG['version']))
|
||||
print("{} version v{} (https://github.com/jr-k/obscreen)".format(self.APPLICATION_NAME, self._CONFIG['version']))
|
||||
sys.exit(0)
|
||||
|
||||
def load_from_env(self) -> None:
|
||||
|
||||
@ -92,14 +92,17 @@ class ContentManager(ModelManager):
|
||||
for content_id, edits in edits_contents.items():
|
||||
self._db.update_by_id(self.TABLE_NAME, content_id, edits)
|
||||
|
||||
def get_contents(self, slide_id: Optional[id] = None, folder_id: Optional[id] = None) -> List[Content]:
|
||||
def get_contents(self, slide_id: Optional[int] = None, folder_id: Optional[int] = None) -> List[Content]:
|
||||
query = " 1=1 "
|
||||
|
||||
if slide_id:
|
||||
query = "{} {}".format(query, "AND slide_id = {}".format(slide_id))
|
||||
|
||||
if folder_id:
|
||||
query = "{} {}".format(query, "AND folder_id = {}".format(folder_id))
|
||||
if folder_id is not None:
|
||||
if folder_id == 0:
|
||||
query = "{} {}".format(query, "AND folder_id is null")
|
||||
else:
|
||||
query = "{} {}".format(query, "AND folder_id = {}".format(folder_id))
|
||||
|
||||
return self.get_by(query=query)
|
||||
|
||||
@ -198,7 +201,7 @@ class ContentManager(ModelManager):
|
||||
content.duration = mp4_duration_with_ffprobe(content.location)
|
||||
|
||||
else:
|
||||
content.location = location
|
||||
content.location = location if location else ''
|
||||
|
||||
self.add_form(content)
|
||||
return self.get_one_by(query="uuid = '{}'".format(content.uuid))
|
||||
|
||||
@ -3,6 +3,7 @@ import re
|
||||
import json
|
||||
import sqlite3
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from sqlite3 import Cursor
|
||||
from typing import Optional, Dict
|
||||
@ -215,6 +216,8 @@ class DatabaseManager:
|
||||
"DELETE FROM settings WHERE name = 'playlist_enabled'",
|
||||
"UPDATE fleet_player_group SET slug = id WHERE slug = '' or slug is null",
|
||||
"UPDATE content SET uuid = id WHERE uuid = '' or uuid is null",
|
||||
"UPDATE slide SET uuid = id WHERE uuid = '' or uuid is null",
|
||||
"UPDATE user SET apikey = \'{}\' || id WHERE apikey = '' or apikey is null".format(str(uuid.uuid4())),
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
|
||||
@ -110,7 +110,7 @@ class FolderManager(ModelManager):
|
||||
for folder_id, edits in edits_folders.items():
|
||||
self._db.update_by_id(self.TABLE_NAME, folder_id, edits)
|
||||
|
||||
def get_folders(self, parent_id: Optional[id] = None) -> List[Folder]:
|
||||
def get_folders(self, parent_id: Optional[int] = None) -> List[Folder]:
|
||||
query = " 1=1 "
|
||||
|
||||
if parent_id:
|
||||
@ -267,3 +267,9 @@ class FolderManager(ModelManager):
|
||||
|
||||
def count_subfolders_for_folder(self, folder_id: int) -> int:
|
||||
return len(self.get_folders(parent_id=folder_id))
|
||||
|
||||
@staticmethod
|
||||
def is_root_drive(path: str):
|
||||
clean_path = path.strip('/')
|
||||
clean_root_path = FOLDER_ROOT_PATH.strip('/')
|
||||
return path == '/' or clean_path == clean_root_path
|
||||
|
||||
@ -81,7 +81,7 @@ class NodePlayerManager(ModelManager):
|
||||
for node_player_id, edits in edits_node_players.items():
|
||||
self._db.update_by_id(self.TABLE_NAME, node_player_id, edits)
|
||||
|
||||
def get_node_players(self, group_id: Optional[int] = None, folder_id: Optional[id] = None, sort: Optional[str] = None, ascending=False) -> List[NodePlayer]:
|
||||
def get_node_players(self, group_id: Optional[int] = None, folder_id: Optional[int] = None, sort: Optional[str] = None, ascending=False) -> List[NodePlayer]:
|
||||
query = " 1=1 "
|
||||
|
||||
if group_id:
|
||||
|
||||
@ -165,9 +165,9 @@ GROUP BY playlist_id;
|
||||
return
|
||||
|
||||
form = {
|
||||
"name": name,
|
||||
"time_sync": time_sync if isinstance(time_sync, bool) else slide.time_sync,
|
||||
"enabled": enabled if isinstance(enabled, bool) else slide.enabled,
|
||||
"name": name if isinstance(name, str) else playlist.name,
|
||||
"time_sync": time_sync if isinstance(time_sync, bool) else playlist.time_sync,
|
||||
"enabled": enabled if isinstance(enabled, bool) else playlist.enabled,
|
||||
}
|
||||
|
||||
self._db.update_by_id(self.TABLE_NAME, id, self.pre_update(form))
|
||||
|
||||
@ -16,6 +16,7 @@ class SlideManager(ModelManager):
|
||||
|
||||
TABLE_NAME = "slides"
|
||||
TABLE_MODEL = [
|
||||
"uuid CHAR(255)",
|
||||
"enabled INTEGER DEFAULT 0",
|
||||
"delegate_duration INTEGER DEFAULT 0",
|
||||
"is_notification INTEGER DEFAULT 0",
|
||||
@ -136,18 +137,19 @@ class SlideManager(ModelManager):
|
||||
for slide_id, slide_position in positions.items():
|
||||
self._db.update_by_id(self.TABLE_NAME, slide_id, {"position": slide_position})
|
||||
|
||||
def update_form(self, id: int, duration: Optional[int] = None, content_id: Optional[int] = None, delegate_duration: Optional[bool] = None, is_notification: bool = False, cron_schedule: Optional[str] = '', cron_schedule_end: Optional[str] = '', enabled: Optional[bool] = None) -> Optional[Slide]:
|
||||
def update_form(self, id: int, duration: Optional[int] = None, content_id: Optional[int] = None, delegate_duration: Optional[bool] = None, is_notification: Optional[bool] = None, cron_schedule: Optional[str] = '', cron_schedule_end: Optional[str] = '', enabled: Optional[bool] = None, position: Optional[int] = None) -> Optional[Slide]:
|
||||
slide = self.get(id)
|
||||
|
||||
if not slide:
|
||||
return
|
||||
|
||||
form = {
|
||||
"duration": duration if duration else slide.duration,
|
||||
"content_id": content_id if content_id else slide.content_id,
|
||||
"duration": duration if isinstance(duration, int) else slide.duration,
|
||||
"content_id": content_id if isinstance(content_id, int) else slide.content_id,
|
||||
"position": position if isinstance(position, int) else slide.position,
|
||||
"enabled": enabled if isinstance(enabled, bool) else slide.enabled,
|
||||
"delegate_duration": delegate_duration if isinstance(delegate_duration, bool) else slide.delegate_duration,
|
||||
"is_notification": True if is_notification else False,
|
||||
"is_notification": is_notification if isinstance(is_notification, bool) else slide.is_notification,
|
||||
"cron_schedule": get_optional_string(cron_schedule),
|
||||
"cron_schedule_end": get_optional_string(cron_schedule_end)
|
||||
}
|
||||
@ -156,7 +158,7 @@ class SlideManager(ModelManager):
|
||||
self.post_update(id)
|
||||
return self.get(id)
|
||||
|
||||
def add_form(self, slide: Union[Slide, Dict]) -> None:
|
||||
def add_form(self, slide: Union[Slide, Dict]) -> Slide:
|
||||
form = slide
|
||||
|
||||
if not isinstance(slide, dict):
|
||||
@ -165,6 +167,7 @@ class SlideManager(ModelManager):
|
||||
|
||||
self._db.add(self.TABLE_NAME, self.pre_add(form))
|
||||
self.post_add(slide.id)
|
||||
return self.get_one_by(query="uuid = '{}'".format(slide.uuid))
|
||||
|
||||
def delete(self, id: int) -> None:
|
||||
slide = self.get(id)
|
||||
|
||||
@ -15,6 +15,7 @@ class UserManager:
|
||||
TABLE_MODEL = [
|
||||
"username CHAR(255)",
|
||||
"password CHAR(255)",
|
||||
"apikey CHAR(255)",
|
||||
"enabled INTEGER DEFAULT 1",
|
||||
"created_by CHAR(255)",
|
||||
"updated_by CHAR(255)",
|
||||
@ -79,8 +80,21 @@ class UserManager:
|
||||
|
||||
return self.hydrate_object(object)
|
||||
|
||||
def get_one_by_username(self, username: str, enabled: bool = None) -> Optional[User]:
|
||||
return self.get_one_by("username = '{}' and (enabled is null or enabled = {})".format(username, int(enabled)))
|
||||
def get_one_by_username(self, username: str, enabled: Optional[bool] = None) -> Optional[User]:
|
||||
query = " username = ? "
|
||||
|
||||
if enabled:
|
||||
query = "{} {}".format(query, "AND enabled = {}".format(int(enabled)))
|
||||
|
||||
return self.get_one_by(query=query, values={"username": username})
|
||||
|
||||
def get_one_by_apikey(self, apikey: str, enabled: Optional[bool] = None) -> Optional[User]:
|
||||
query = " apikey = ? "
|
||||
|
||||
if enabled:
|
||||
query = "{} {}".format(query, "AND enabled = {}".format(int(enabled)))
|
||||
|
||||
return self.get_one_by(query=query, values={"apikey": apikey})
|
||||
|
||||
def count_all_enabled(self):
|
||||
return len(self.get_by("enabled = 1"))
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from typing import Optional, Union
|
||||
from src.util.utils import str_to_enum
|
||||
@ -7,7 +8,8 @@ from src.util.utils import str_to_enum
|
||||
|
||||
class Slide:
|
||||
|
||||
def __init__(self, playlist_id: Optional[int] = None, content_id: Optional[int] = None, delegate_duration=False, duration: int = 3, is_notification: bool = False, enabled: bool = False, position: int = 999, id: Optional[int] = None, cron_schedule: Optional[str] = None, cron_schedule_end: Optional[str] = None, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None):
|
||||
def __init__(self, uuid: str = '', playlist_id: Optional[int] = None, content_id: Optional[int] = None, delegate_duration=False, duration: int = 3, is_notification: bool = False, enabled: bool = False, position: int = 999, id: Optional[int] = None, cron_schedule: Optional[str] = None, cron_schedule_end: Optional[str] = None, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None):
|
||||
self._uuid = uuid if uuid else self.generate_and_set_uuid()
|
||||
self._id = id if id else None
|
||||
self._playlist_id = playlist_id
|
||||
self._content_id = content_id
|
||||
@ -23,10 +25,23 @@ class Slide:
|
||||
self._created_at = int(created_at if created_at else time.time())
|
||||
self._updated_at = int(updated_at if updated_at else time.time())
|
||||
|
||||
def generate_and_set_uuid(self) -> str:
|
||||
self._uuid = str(uuid.uuid4())
|
||||
|
||||
return self._uuid
|
||||
|
||||
@property
|
||||
def id(self) -> Optional[int]:
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def uuid(self) -> str:
|
||||
return self._uuid
|
||||
|
||||
@uuid.setter
|
||||
def uuid(self, value: str):
|
||||
self._uuid = value
|
||||
|
||||
@property
|
||||
def created_by(self) -> str:
|
||||
return self._created_by
|
||||
@ -134,6 +149,7 @@ class Slide:
|
||||
def __str__(self) -> str:
|
||||
return f"Slide(" \
|
||||
f"id='{self.id}',\n" \
|
||||
f"uuid='{self.uuid}',\n" \
|
||||
f"enabled='{self.enabled}',\n" \
|
||||
f"is_notification='{self.is_notification}',\n" \
|
||||
f"duration='{self.duration}',\n" \
|
||||
@ -160,6 +176,7 @@ class Slide:
|
||||
def to_dict(self) -> dict:
|
||||
slide = {
|
||||
"id": self.id,
|
||||
"uuid": self.uuid,
|
||||
"enabled": self.enabled,
|
||||
"is_notification": self.is_notification,
|
||||
"position": self.position,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from typing import Optional, Union
|
||||
|
||||
@ -8,16 +9,22 @@ class User:
|
||||
|
||||
DEFAULT_USER = 'admin'
|
||||
|
||||
def __init__(self, username: str = '', password: str = '', enabled: bool = True, id: Optional[int] = None, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None):
|
||||
def __init__(self, username: str = '', password: str = '', apikey: str = '', enabled: bool = True, id: Optional[int] = None, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None):
|
||||
self._id = id if id else None
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._apikey = apikey if apikey else self.set_new_apikey()
|
||||
self._enabled = enabled
|
||||
self._created_by = created_by if created_by else None
|
||||
self._updated_by = updated_by if updated_by else None
|
||||
self._created_at = int(created_at if created_at else time.time())
|
||||
self._updated_at = int(updated_at if updated_at else time.time())
|
||||
|
||||
def set_new_apikey(self) -> str:
|
||||
self._apikey = str(uuid.uuid4())
|
||||
|
||||
return self._apikey
|
||||
|
||||
@property
|
||||
def id(self) -> Optional[int]:
|
||||
return self._id
|
||||
@ -38,6 +45,14 @@ class User:
|
||||
def password(self, value: str):
|
||||
self._password = value
|
||||
|
||||
@property
|
||||
def apikey(self) -> str:
|
||||
return self._apikey
|
||||
|
||||
@apikey.setter
|
||||
def apikey(self, value: str):
|
||||
self._apikey = value
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._enabled)
|
||||
|
||||
@ -3,6 +3,7 @@ import shutil
|
||||
import logging
|
||||
import inspect
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
from src.interface.ObPlugin import ObPlugin
|
||||
from src.interface.ObController import ObController
|
||||
@ -55,6 +56,9 @@ class PluginStore:
|
||||
return self._hooks
|
||||
|
||||
def find_plugins_in_directory(self, directory: str) -> list:
|
||||
plugin_type = Path(directory).stem.capitalize()
|
||||
logging.info("#")
|
||||
logging.info("[plugin] {}...".format(plugin_type))
|
||||
plugins = []
|
||||
for root, dirs, files in os.walk('{}/{}'.format(self._kernel.get_application_dir(), directory)):
|
||||
for file in files:
|
||||
@ -116,6 +120,10 @@ class PluginStore:
|
||||
self._hooks[hook_type] = sorted(self._hooks[hook_type], key=lambda hook_reg: hook_reg.priority, reverse=True)
|
||||
|
||||
def setup_plugin(self, plugin: ObPlugin) -> None:
|
||||
# LANGS
|
||||
self._model_store.lang().load(directory=plugin.get_directory(), prefix=plugin.use_id())
|
||||
self._model_store.variable().reload()
|
||||
|
||||
# VARIABLES
|
||||
variables = plugin.use_variables() + [
|
||||
plugin.add_variable(
|
||||
@ -123,7 +131,8 @@ class PluginStore:
|
||||
value=False,
|
||||
type=VariableType.BOOL,
|
||||
editable=True,
|
||||
description=self._model_store.lang().translate("common_enable_plugin")
|
||||
description=self._model_store.lang().translate("common_enable_plugin"),
|
||||
description_edition=plugin.use_help_on_activation()
|
||||
)
|
||||
]
|
||||
|
||||
@ -131,10 +140,6 @@ class PluginStore:
|
||||
if variable.name in self._dead_variables_candidates:
|
||||
del self._dead_variables_candidates[variable.name]
|
||||
|
||||
# LANGS
|
||||
self._model_store.lang().load(directory=plugin.get_directory(), prefix=plugin.use_id())
|
||||
self._model_store.variable().reload()
|
||||
|
||||
if not self.is_plugin_enabled(plugin):
|
||||
return
|
||||
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import os
|
||||
import time
|
||||
from waitress import serve
|
||||
from functools import wraps
|
||||
|
||||
from flask import Flask, send_from_directory, redirect, url_for
|
||||
from flask_login import LoginManager, current_user
|
||||
from flask import Flask, send_from_directory, redirect, url_for, request, jsonify, make_response, abort
|
||||
from flask_login import LoginManager, current_user, login_user
|
||||
from flask_restx import Api
|
||||
|
||||
from src.manager.UserManager import UserManager
|
||||
from src.service.ModelStore import ModelStore
|
||||
@ -19,13 +21,15 @@ from src.controller.SysinfoController import SysinfoController
|
||||
from src.controller.SettingsController import SettingsController
|
||||
from src.controller.CoreController import CoreController
|
||||
from src.constant.WebDirConstant import WebDirConstant
|
||||
from src.exceptions.HttpClientException import HttpClientException
|
||||
from plugins.system.CoreApi.exception.ContentPathMissingException import ContentPathMissingException
|
||||
|
||||
|
||||
class WebServer:
|
||||
|
||||
def __init__(self, kernel, model_store: ModelStore, template_renderer: TemplateRenderer):
|
||||
self._app = None
|
||||
self._auth_enabled = False
|
||||
self._api = None
|
||||
self._login_manager = None
|
||||
self._kernel = kernel
|
||||
self._model_store = model_store
|
||||
@ -33,12 +37,20 @@ class WebServer:
|
||||
self._debug = self._model_store.config().map().get('debug')
|
||||
self.setup()
|
||||
|
||||
@property
|
||||
def api(self) -> Api:
|
||||
return self._api
|
||||
|
||||
def get_max_upload_size(self):
|
||||
return self._model_store.variable().map().get('slide_upload_limit').as_int() * 1024 * 1024
|
||||
|
||||
def run(self) -> None:
|
||||
serve(
|
||||
self._app,
|
||||
host=self._model_store.config().map().get('bind'),
|
||||
port=self._model_store.config().map().get('port'),
|
||||
threads=100
|
||||
threads=100,
|
||||
max_request_body_size=self.get_max_upload_size(),
|
||||
)
|
||||
|
||||
def reload(self) -> None:
|
||||
@ -49,6 +61,7 @@ class WebServer:
|
||||
self._setup_web_globals()
|
||||
self._setup_web_errors()
|
||||
self._setup_web_controllers()
|
||||
self._setup_api()
|
||||
|
||||
def get_app(self):
|
||||
return self._app
|
||||
@ -73,7 +86,8 @@ class WebServer:
|
||||
)
|
||||
|
||||
self._app.config['UPLOAD_FOLDER'] = "{}/{}".format(WebDirConstant.FOLDER_STATIC, WebDirConstant.FOLDER_STATIC_WEB_UPLOADS)
|
||||
self._app.config['MAX_CONTENT_LENGTH'] = self._model_store.variable().map().get('slide_upload_limit').as_int() * 1024 * 1024
|
||||
self._app.config['MAX_CONTENT_LENGTH'] = self.get_max_upload_size()
|
||||
self._app.config['ERROR_404_HELP'] = False
|
||||
|
||||
self._setup_flask_login()
|
||||
|
||||
@ -90,9 +104,12 @@ class WebServer:
|
||||
def load_user(user_id):
|
||||
return self._model_store.user().get(user_id)
|
||||
|
||||
def is_auth_enabled(self) -> bool:
|
||||
return self._model_store.variable().map().get('auth_enabled').as_bool()
|
||||
|
||||
def auth_required(self, f):
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not self._model_store.variable().map().get('auth_enabled').as_bool():
|
||||
if not self.is_auth_enabled():
|
||||
return f(*args, **kwargs)
|
||||
|
||||
if not current_user.is_authenticated:
|
||||
@ -113,13 +130,83 @@ class WebServer:
|
||||
PlaylistController(self._kernel, self, self._app, self.auth_required, self._model_store, self._template_renderer)
|
||||
AuthController(self._kernel, self, self._app, self.auth_required, self._model_store, self._template_renderer)
|
||||
|
||||
def _setup_api(self) -> None:
|
||||
security = None
|
||||
authorizations = None
|
||||
|
||||
if self.is_auth_enabled():
|
||||
security = 'apikey'
|
||||
authorizations = {
|
||||
'apikey': {
|
||||
'type': 'apiKey',
|
||||
'in': 'header',
|
||||
'name': 'Authorization'
|
||||
}
|
||||
}
|
||||
|
||||
self._api = Api(
|
||||
self._app,
|
||||
version=self._model_store.config().map().get('version'),
|
||||
title="{} {}".format(self._model_store.config().map().get('application_name'), "API"),
|
||||
description='API Documentation with Swagger',
|
||||
endpoint='api',
|
||||
doc='/api',
|
||||
security=security,
|
||||
authorizations=authorizations
|
||||
)
|
||||
|
||||
def _setup_web_globals(self) -> None:
|
||||
@self._app.context_processor
|
||||
def inject_global_vars() -> dict:
|
||||
return self._template_renderer.get_view_globals()
|
||||
|
||||
def _setup_web_errors(self) -> None:
|
||||
@self._app.errorhandler(404)
|
||||
def not_found(e):
|
||||
return send_from_directory(self.get_template_dir(), 'core/error404.html'), 404
|
||||
def handle_error(error):
|
||||
if request.headers.get('Content-Type') == 'application/json' or request.headers.get('Accept') == 'application/json':
|
||||
response = jsonify({
|
||||
'error': {
|
||||
'code': error.code,
|
||||
'message': error.description
|
||||
}
|
||||
})
|
||||
return make_response(response, error.code)
|
||||
|
||||
if error.code == 404:
|
||||
return send_from_directory(self.get_template_dir(), 'core/error404.html'), 404
|
||||
|
||||
return error
|
||||
|
||||
self._app.register_error_handler(400, handle_error)
|
||||
self._app.register_error_handler(404, handle_error)
|
||||
self._app.register_error_handler(409, handle_error)
|
||||
self._app.register_error_handler(HttpClientException, handle_error)
|
||||
|
||||
|
||||
def create_require_api_key_decorator(web_server: WebServer):
|
||||
def require_api_key(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not web_server.is_auth_enabled():
|
||||
return
|
||||
|
||||
auth_header = request.headers.get('Authorization')
|
||||
|
||||
if auth_header:
|
||||
apikey = auth_header
|
||||
parts = auth_header.split()
|
||||
if parts[0].lower() == 'bearer':
|
||||
apikey = parts[1]
|
||||
|
||||
user = web_server._model_store.user().get_one_by_apikey(apikey)
|
||||
|
||||
if user:
|
||||
login_user(user)
|
||||
return user
|
||||
|
||||
return abort(403, 'Forbidden: You do not have access to this resource.')
|
||||
|
||||
return abort(401, 'Invalid or missing API key.')
|
||||
|
||||
return decorated_function()
|
||||
|
||||
return require_api_key
|
||||
|
||||
@ -59,6 +59,30 @@ def camel_to_snake(camel: str) -> str:
|
||||
return CAMEL_CASE_TO_SNAKE_CASE_PATTERN.sub('_', camel).lower()
|
||||
|
||||
|
||||
def str_datetime_to_cron(datetime_str: str) -> str:
|
||||
datetime_obj = datetime.strptime(datetime_str, '%Y-%m-%d %H:%M')
|
||||
return "{} {} {} {} * {}".format(
|
||||
datetime_obj.minute,
|
||||
datetime_obj.hour,
|
||||
datetime_obj.day,
|
||||
datetime_obj.month,
|
||||
datetime_obj.year
|
||||
)
|
||||
|
||||
|
||||
def str_weekdaytime_to_cron(weekday: int, time_str: str) -> str:
|
||||
if weekday < 1 or weekday > 7:
|
||||
raise ValueError('Weekday must be between [1-7]')
|
||||
|
||||
time_obj = datetime.strptime(time_str, '%H:%M').time()
|
||||
|
||||
return "{} {} * * {}".format(
|
||||
time_obj.minute,
|
||||
time_obj.hour,
|
||||
weekday
|
||||
)
|
||||
|
||||
|
||||
def is_cron_in_datetime_moment(expression: str) -> bool:
|
||||
pattern = re.compile(r'^(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+\*\s+(\d+)$')
|
||||
return bool(pattern.match(expression))
|
||||
@ -300,3 +324,14 @@ def slugify_next(slug: str) -> str:
|
||||
return f"{parts[0]}-{next_number}"
|
||||
else:
|
||||
return f"{slug}-1"
|
||||
|
||||
|
||||
def str_to_bool(value):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value.lower() in {'false', 'f', '0', 'no', 'n'}:
|
||||
return False
|
||||
elif value.lower() in {'true', 't', '1', 'yes', 'y'}:
|
||||
return True
|
||||
else:
|
||||
raise ValueError('Boolean value expected.')
|
||||
|
||||
@ -1 +1 @@
|
||||
2.2.3
|
||||
2.3.0
|
||||
|
||||
@ -1,12 +1,29 @@
|
||||
<div class="tiles users">
|
||||
<div class="tiles-inner">
|
||||
{% for user in users %}
|
||||
<div class="user-item tile-item {% if not user.enabled %}disabled{% endif %}" data-level="{{ user.id }}"
|
||||
<div class="user-item tile-item {% if not user.enabled %}disabled{% endif %}" data-id="{{ user.id }}"
|
||||
data-entity="{{ user.to_json({"created_by": track_created(user).username, "updated_by": track_updated(user).username}) }}">
|
||||
<div class="tile-body">
|
||||
{{ truncate(user.username, 100, '...') }}
|
||||
</div>
|
||||
{% if plugin_core_api_enabled %}
|
||||
<div class="tile-metrics">
|
||||
<div class="form-group">
|
||||
<div class="widget">
|
||||
<label for="">
|
||||
Token:
|
||||
</label>
|
||||
<input type="text" class="input-token disabled" disabled value="{{ user.apikey|length * 2 * '•' }}" data-public="{{ user.apikey }}" data-private="{{ user.apikey|length * 2 * '•' }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="tile-tail">
|
||||
{% if plugin_core_api_enabled %}
|
||||
<button type="button" class="btn btn-other user-token-reveal">
|
||||
<i class="fa fa-eye"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<a href="javascript:void(0);" class="item-edit user-edit btn btn-info">
|
||||
<i class="fa fa-pencil"></i>
|
||||
</a>
|
||||
|
||||
@ -13,9 +13,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="variable-edit-description-edition" class="alert alert-danger">
|
||||
|
||||
</div>
|
||||
<div id="variable-edit-description-edition" class="alert alert-danger"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="variable-edit-value" id="variable-edit-description"></label>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user