Merge pull request #127 from jr-k/develop

Release v2.2.4
This commit is contained in:
JRK 2024-08-04 18:04:17 +02:00 committed by GitHub
commit 62fe7ee1fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1288 additions and 69 deletions

View File

@ -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

View File

@ -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();
});

View File

@ -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);
});

View File

@ -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;
}
}
}
}

View 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 []

View 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'}

View 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)

View 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'})

View File

@ -0,0 +1,6 @@
from src.exceptions.HttpClientException import HttpClientException
class ContentNotFoundException(HttpClientException):
code = 404
description = "Content not found"

View File

@ -0,0 +1,6 @@
from src.exceptions.HttpClientException import HttpClientException
class ContentPathMissingException(HttpClientException):
code = 400
description = "Path is required"

View File

@ -0,0 +1,6 @@
from src.exceptions.HttpClientException import HttpClientException
class FolderNotEmptyException(HttpClientException):
code = 400
description = "Folder is not empty"

View File

@ -0,0 +1,6 @@
from src.exceptions.HttpClientException import HttpClientException
class FolderNotFoundException(HttpClientException):
code = 404
description = "Folder not found"

View 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"
}

View 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"
}

View 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"
}

View 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"
}

View File

@ -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 []

View File

@ -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')

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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 []

View File

@ -5,3 +5,4 @@ waitress
flask-login
pysqlite3
psutil
flask-restx

View File

@ -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):

View File

@ -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}

View File

@ -0,0 +1,5 @@
from werkzeug.exceptions import HTTPException
class HttpClientException(HTTPException):
pass

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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))

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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))

View File

@ -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)

View File

@ -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"))

View File

@ -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,

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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.')

View File

@ -1 +1 @@
2.2.3
2.3.0

View File

@ -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>

View File

@ -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>