From b1e5cda67a021e1cd00d8b82feaf8de9c60c7ea4 Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 28 Sep 2024 14:13:38 +0200 Subject: [PATCH 01/11] chore: update dependencies --- backend/nebula/version.py | 2 +- backend/pyproject.toml | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/nebula/version.py b/backend/nebula/version.py index 52b23b00..a3e63b64 100644 --- a/backend/nebula/version.py +++ b/backend/nebula/version.py @@ -1 +1 @@ -__version__ = "6.0.6" +__version__ = "6.0.7" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c5f8aa8e..1e0d8b97 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "nebula" -version = "6.0.6" +version = "6.0.7" description = "Open source broadcast automation system" authors = ["Nebula Broadcast "] @@ -18,31 +18,31 @@ build-backend = "poetry.core.masonry.api" # [tool.poetry.dependencies] -python = "^3.10" -aiofiles = "^23.2.1" +python = "^3.12" +aiofiles = "^24.1.0" asyncpg = "^0.29.0" email-validator = "^2.1.1" -fastapi = "^0.110.0" +fastapi = "^0.115.0" geoip2 = "^4.8.0" -granian = "^1.1.1" -gunicorn = "^21.2.0" -httpx = "^0.27.0" +granian = "^1.6.0" +gunicorn = "^22.0.0" +httpx = "^0.27.2" mistune = "^3.0.1" nxtools = "^1.6" -pydantic = "^2.6.3" +pydantic = "^2.9.2" python-dotenv = "^1.0.1" -redis = "^5.0.2" -requests = "^2.31.0" -rich = "^13.7.1" +redis = "^5.1.0" +requests = "^2.32.3" +rich = "^13.8.0" shortuuid = "^1.0.12" user-agents = "^2.2.0" -uvicorn = {extras = ["standard"], version = "0.27.1"} +uvicorn = {extras = ["standard"], version = "0.31.0"} [tool.poetry.dev-dependencies] -mypy = "^1.9" +mypy = "^1.11" pytest = "^8.0" pytest-asyncio = "^0.20.3" -ruff = "^0.3.1" +ruff = "^0.6.8" # # Tools From 0a241b3bff302ac6ed77649f4ae2a8a6898c3020 Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 28 Sep 2024 14:23:44 +0200 Subject: [PATCH 02/11] chore: fix endpoint metadata --- backend/api/auth.py | 8 ++++---- backend/api/browse.py | 3 ++- backend/api/delete.py | 6 +++--- backend/api/get.py | 4 ++-- backend/api/init/__init__.py | 4 ++-- backend/api/jobs/actions.py | 4 ++-- backend/api/jobs/jobs.py | 4 ++-- backend/api/jobs/send.py | 4 ++-- backend/api/order/__init__.py | 4 ++-- backend/api/playout/__init__.py | 4 ++-- backend/api/rundown/__init__.py | 4 ++-- backend/api/scheduler/__init__.py | 4 ++-- backend/api/services.py | 4 ++-- backend/api/set.py | 4 ++-- backend/api/solve.py | 3 ++- backend/api/upload.py | 6 +++--- backend/api/users.py | 8 ++++---- 17 files changed, 40 insertions(+), 38 deletions(-) diff --git a/backend/api/auth.py b/backend/api/auth.py index 5ed4ee4a..fa8a93b0 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -129,8 +129,8 @@ class LogoutRequest(APIRequest): This request will invalidate the access token used in the Authorization header. """ - name: str = "logout" - title: str = "Logout" + name = "logout" + title = "Logout" async def handle(self, authorization: str | None = Header(None)) -> None: if not authorization: @@ -154,8 +154,8 @@ class SetPassword(APIRequest): the current user must be an admin, otherwise a 403 error is returned. """ - name: str = "password" - title: str = "Set password" + name = "password" + title = "Set password" async def handle( self, diff --git a/backend/api/browse.py b/backend/api/browse.py index 233b214e..775d29f6 100644 --- a/backend/api/browse.py +++ b/backend/api/browse.py @@ -256,7 +256,8 @@ def build_query( class Request(APIRequest): """Browse the assets database.""" - name: str = "browse" + name = "browse" + title = "Browse assets" response_model = BrowseResponseModel async def handle( diff --git a/backend/api/delete.py b/backend/api/delete.py index 67305029..36124f3f 100644 --- a/backend/api/delete.py +++ b/backend/api/delete.py @@ -21,10 +21,10 @@ class DeleteRequestModel(RequestModel): class Request(APIRequest): - """Delete object(s)""" + """Delete one or multiple objects from the database""" - name: str = "delete" - title: str = "Delete objects" + name = "delete" + title = "Delete objects" responses: list[int] = [204, 401, 403] async def handle( diff --git a/backend/api/get.py b/backend/api/get.py index 72973ffc..70252778 100644 --- a/backend/api/get.py +++ b/backend/api/get.py @@ -62,8 +62,8 @@ def can_access_object(user: nebula.User, meta: dict[str, Any]) -> bool: class Request(APIRequest): """Get a list of objects""" - name: str = "get" - title: str = "Get objects" + name = "get" + title = "Get objects" response_model = GetResponseModel async def handle( diff --git a/backend/api/init/__init__.py b/backend/api/init/__init__.py index 3d133097..b9cd9a79 100644 --- a/backend/api/init/__init__.py +++ b/backend/api/init/__init__.py @@ -63,8 +63,8 @@ class Request(APIRequest): (motd) and OAuth2 options are returned. """ - name: str = "init" - title: str = "Login" + name = "init" + title = "Login" response_model = InitResponseModel async def handle( diff --git a/backend/api/jobs/actions.py b/backend/api/jobs/actions.py index 43c6af6e..44d67d1b 100644 --- a/backend/api/jobs/actions.py +++ b/backend/api/jobs/actions.py @@ -33,8 +33,8 @@ class ActionsResponseModel(ResponseModel): class ActionsRequest(APIRequest): """List available actions for given list of assets""" - name: str = "actions" - title: str = "Get available actions" + name = "actions" + title = "Get available actions" response_model = ActionsResponseModel async def handle( diff --git a/backend/api/jobs/jobs.py b/backend/api/jobs/jobs.py index 4b392ec8..bee0803f 100644 --- a/backend/api/jobs/jobs.py +++ b/backend/api/jobs/jobs.py @@ -186,8 +186,8 @@ async def set_priority(id_job: int, priority: int, user: nebula.User) -> None: class JobsRequest(APIRequest): """Get list of jobs, abort or restart them""" - name: str = "jobs" - title: str = "List and control jobs" + name = "jobs" + title = "List and control jobs" response_model = JobsResponseModel async def handle( diff --git a/backend/api/jobs/send.py b/backend/api/jobs/send.py index f1785356..3d40daff 100644 --- a/backend/api/jobs/send.py +++ b/backend/api/jobs/send.py @@ -154,8 +154,8 @@ async def send_to( class SendRequest(APIRequest): """Create jobs for a given list of assets.""" - name: str = "send" - title: str = "Send to" + name = "send" + title = "Send to" response_model = SendResponseModel async def handle( diff --git a/backend/api/order/__init__.py b/backend/api/order/__init__.py index 42f0abab..b1a0509e 100644 --- a/backend/api/order/__init__.py +++ b/backend/api/order/__init__.py @@ -10,8 +10,8 @@ class Request(APIRequest): """Set the order of items of a rundown""" - name: str = "order" - title: str = "Order" + name = "order" + title = "Order" response_model = OrderResponseModel async def handle( diff --git a/backend/api/playout/__init__.py b/backend/api/playout/__init__.py index e2465a41..82ba3473 100644 --- a/backend/api/playout/__init__.py +++ b/backend/api/playout/__init__.py @@ -11,8 +11,8 @@ class Request(APIRequest): """Control a playout server""" - name: str = "playout" - title: str = "Playout" + name = "playout" + title = "Playout" response_model = PlayoutResponseModel def handle( diff --git a/backend/api/rundown/__init__.py b/backend/api/rundown/__init__.py index 457a9665..91738647 100644 --- a/backend/api/rundown/__init__.py +++ b/backend/api/rundown/__init__.py @@ -9,8 +9,8 @@ class Request(APIRequest): """Get a rundown""" - name: str = "rundown" - title: str = "Get rundown" + name = "rundown" + title = "Get rundown" response_model = RundownResponseModel async def handle( diff --git a/backend/api/scheduler/__init__.py b/backend/api/scheduler/__init__.py index c73896e7..1e005423 100644 --- a/backend/api/scheduler/__init__.py +++ b/backend/api/scheduler/__init__.py @@ -10,8 +10,8 @@ class Request(APIRequest): """Modify a channel schedule""" - name: str = "scheduler" - title: str = "Scheduler" + name = "scheduler" + title = "Scheduler" response_model = SchedulerResponseModel async def handle( diff --git a/backend/api/services.py b/backend/api/services.py index 6308630a..782e066d 100644 --- a/backend/api/services.py +++ b/backend/api/services.py @@ -48,8 +48,8 @@ class ServicesResponseModel(ResponseModel): class Request(APIRequest): """List and control installed services.""" - name: str = "services" - title: str = "Service control" + name = "services" + title = "Service control" response_model = ServicesResponseModel async def handle( diff --git a/backend/api/set.py b/backend/api/set.py index 1b9c3b31..c31826c3 100644 --- a/backend/api/set.py +++ b/backend/api/set.py @@ -154,8 +154,8 @@ async def can_modify_object(obj: BaseObject, user: nebula.User) -> None: class OperationsRequest(APIRequest): """Create or update multiple objects in one requests.""" - name: str = "ops" - title: str = "Save multiple objects" + name = "ops" + title = "Operations" response_model = OperationsResponseModel async def handle( diff --git a/backend/api/solve.py b/backend/api/solve.py index 211d260d..2bbbc86e 100644 --- a/backend/api/solve.py +++ b/backend/api/solve.py @@ -20,7 +20,8 @@ class SolveRequestModel(RequestModel): class Request(APIRequest): """Solve a rundown placeholder""" - name: str = "solve" + name = "solve" + title = "Solve" responses: list[int] = [200] async def handle( diff --git a/backend/api/upload.py b/backend/api/upload.py index 58e7f366..de2b3d84 100644 --- a/backend/api/upload.py +++ b/backend/api/upload.py @@ -17,9 +17,9 @@ class UploadRequest(APIRequest): This endpoint is used by the web frontend to upload media files. """ - name: str = "upload" - path: str = "/upload/{id_asset}" - title: str = "Get objects" + name = "upload" + path = "/upload/{id_asset}" + title = "Get objects" response_class = Response async def handle( diff --git a/backend/api/users.py b/backend/api/users.py index 77355d61..43fa3820 100644 --- a/backend/api/users.py +++ b/backend/api/users.py @@ -43,8 +43,8 @@ class UserListResponseModel(ResponseModel): class UserListRequest(APIRequest): """Get a list of users""" - name: str = "user_list" - title: str = "Get a list of users" + name = "user_list" + title = "Get user list" response_model = UserListResponseModel async def handle(self, user: CurrentUser) -> UserListResponseModel: @@ -76,8 +76,8 @@ async def handle(self, user: CurrentUser) -> UserListResponseModel: class SaveUserRequest(APIRequest): """Save user data""" - name: str = "save_user" - title: str = "Save user data" + name = "save_user" + title = "Save user data" responses = [204, 201] async def handle(self, user: CurrentUser, payload: UserModel) -> Response: From f1b0f3821a6e94e7412f589def6e2026d7169dce Mon Sep 17 00:00:00 2001 From: Martastain Date: Sat, 28 Sep 2024 19:58:26 +0200 Subject: [PATCH 03/11] doc: updated api docs --- backend/api/jobs/jobs.py | 9 ++------- backend/api/rundown/__init__.py | 15 ++++++++++++++- backend/api/services.py | 8 +++++++- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/backend/api/jobs/jobs.py b/backend/api/jobs/jobs.py index bee0803f..4a413a13 100644 --- a/backend/api/jobs/jobs.py +++ b/backend/api/jobs/jobs.py @@ -170,19 +170,14 @@ async def abort_job(id_job: int, user: nebula.User) -> None: message=message, ) - async def set_priority(id_job: int, priority: int, user: nebula.User) -> None: + """Set the priority of a job if the user has the necessary permissions""" if not await can_user_control_job(user, id_job): raise nebula.ForbiddenException("You cannot set priority of this job") nebula.log.info(f"Setting priority of job {id_job} to {priority}", user=user.name) - query = """ - UPDATE jobs SET - priority = $1 - WHERE id = $2 - """ + query = "UPDATE jobs SET priority = $1 WHERE id = $2" await nebula.db.execute(query, priority, id_job) - class JobsRequest(APIRequest): """Get list of jobs, abort or restart them""" diff --git a/backend/api/rundown/__init__.py b/backend/api/rundown/__init__.py index 91738647..0e7d74f7 100644 --- a/backend/api/rundown/__init__.py +++ b/backend/api/rundown/__init__.py @@ -7,7 +7,20 @@ class Request(APIRequest): - """Get a rundown""" + """Get a rundown for the given day + + Date is specified in YYYY-mm-dd format and it takes in + account the channel broadcast start setting (usually 7:00) + + Rundown is a list of items that are scheduled to be broadcasted + in the given day. Items are ordered by their start time and contain + additional metadata such as scheduled time, actual broadcast time, + etc. During the broadcast, the actual time is updated to reflect + the real broadcast time. + + Additional non-playable items are also included in the rundown, + such as blocks, placeholders, loop markers and so on. + """ name = "rundown" title = "Get rundown" diff --git a/backend/api/services.py b/backend/api/services.py index 782e066d..40f7a116 100644 --- a/backend/api/services.py +++ b/backend/api/services.py @@ -46,7 +46,13 @@ class ServicesResponseModel(ResponseModel): class Request(APIRequest): - """List and control installed services.""" + """ + List and control installed services. + + This endpoint allows users to list all installed services and control their state. + Users can start, stop, or toggle the autostart setting of a service by providing + the respective service ID in the request. + """ name = "services" title = "Service control" From bf24af3d59e9a0f228e5ce11b5fe247e23d7021f Mon Sep 17 00:00:00 2001 From: Martastain Date: Mon, 7 Oct 2024 22:59:36 +0200 Subject: [PATCH 04/11] chore: more ruff --- backend/api/get.py | 4 +--- backend/api/jobs/jobs.py | 2 ++ backend/nebula/metadata/normalize.py | 4 +--- backend/nebula/objects/user.py | 5 +---- backend/server/storage_monitor.py | 4 +--- backend/server/websocket.py | 4 +--- backend/setup/metatypes.py | 2 +- 7 files changed, 8 insertions(+), 17 deletions(-) diff --git a/backend/api/get.py b/backend/api/get.py index 70252778..2de02e9b 100644 --- a/backend/api/get.py +++ b/backend/api/get.py @@ -43,9 +43,7 @@ def can_access_object(user: nebula.User, meta: dict[str, Any]) -> bool: if user.is_admin or (user.id in meta.get("assignees", [])): return True elif user.is_limited: - if meta.get("created_by") != user.id: - return False - return True + return meta.get("created_by") == user.id if id_folder := meta.get("id_folder"): # Users can view assets in folders they have access to return user.can("asset_view", id_folder) diff --git a/backend/api/jobs/jobs.py b/backend/api/jobs/jobs.py index 4a413a13..5c515baa 100644 --- a/backend/api/jobs/jobs.py +++ b/backend/api/jobs/jobs.py @@ -170,6 +170,7 @@ async def abort_job(id_job: int, user: nebula.User) -> None: message=message, ) + async def set_priority(id_job: int, priority: int, user: nebula.User) -> None: """Set the priority of a job if the user has the necessary permissions""" if not await can_user_control_job(user, id_job): @@ -178,6 +179,7 @@ async def set_priority(id_job: int, priority: int, user: nebula.User) -> None: query = "UPDATE jobs SET priority = $1 WHERE id = $2" await nebula.db.execute(query, priority, id_job) + class JobsRequest(APIRequest): """Get list of jobs, abort or restart them""" diff --git a/backend/nebula/metadata/normalize.py b/backend/nebula/metadata/normalize.py index f5ef27cc..bd51ac7a 100644 --- a/backend/nebula/metadata/normalize.py +++ b/backend/nebula/metadata/normalize.py @@ -23,9 +23,7 @@ def is_serializable(value: Any) -> bool: This is used to check if a value can be stored in the database. """ - if isinstance(value, (str, int, float, bool, dict, list, tuple)): - return True - return False + return bool(isinstance(value, (str, int, float, bool, dict, list, tuple))) def normalize_meta(key: str, value: Any) -> Any: diff --git a/backend/nebula/objects/user.py b/backend/nebula/objects/user.py index 12cf44e2..9cd83e48 100644 --- a/backend/nebula/objects/user.py +++ b/backend/nebula/objects/user.py @@ -142,10 +142,7 @@ def can( if self[key] == value: return True - if isinstance(self[key], list) and value in self[key]: - return True - - return False + return bool(isinstance(self[key], list) and value in self[key]) @property def is_admin(self) -> bool: diff --git a/backend/server/storage_monitor.py b/backend/server/storage_monitor.py index 35a83f61..bd0897ed 100644 --- a/backend/server/storage_monitor.py +++ b/backend/server/storage_monitor.py @@ -16,9 +16,7 @@ async def exec_mount(cmd: str) -> bool: proc = subprocess.Popen(cmd, shell=True) # noqa while proc.poll() is None: await asyncio.sleep(0.1) - if proc.returncode: - return False - return True + return not proc.returncode # def handle_nfs_storage(storage: Storage): diff --git a/backend/server/websocket.py b/backend/server/websocket.py index 1f520ffd..6c10722c 100644 --- a/backend/server/websocket.py +++ b/backend/server/websocket.py @@ -70,9 +70,7 @@ async def receive(self) -> dict[str, Any] | None: def is_valid(self) -> bool: if self.disconnected: return False - if not self.authorized and (time.time() - self.created_at > 3): - return False - return True + return not (not self.authorized and time.time() - self.created_at > 3) class Messaging(BackgroundTask): diff --git a/backend/setup/metatypes.py b/backend/setup/metatypes.py index 77ffa4fe..3618aeed 100644 --- a/backend/setup/metatypes.py +++ b/backend/setup/metatypes.py @@ -15,7 +15,7 @@ async def setup_metatypes(meta_types: dict[str, Any], db: DatabaseConnection) -> aliases[lang] = {} trans_table_fname = os.path.join("schema", f"meta-aliases-{lang}.json") - async with aiofiles.open(trans_table_fname, "r") as f: + async with aiofiles.open(trans_table_fname) as f: adata = json_loads(await f.read()) for key, alias, header, description in adata: From 72bd7ce6fc89d1fe11956ed72d5bfe418792e148 Mon Sep 17 00:00:00 2001 From: Martastain Date: Tue, 15 Oct 2024 22:49:58 +0200 Subject: [PATCH 05/11] refactor: clean-up complex endpoints --- backend/api/init/__init__.py | 105 +-------------- .../init/{settings.py => client_settings.py} | 0 backend/api/init/init_request.py | 122 ++++++++++++++++++ backend/api/jobs/__init__.py | 6 +- .../jobs/{actions.py => actions_request.py} | 0 backend/api/jobs/{jobs.py => jobs_request.py} | 0 backend/api/jobs/{send.py => send_request.py} | 0 backend/api/order/__init__.py | 32 +---- backend/api/order/order_request.py | 31 +++++ .../order/{order.py => set_rundown_order.py} | 0 backend/api/playout/__init__.py | 71 +--------- backend/api/playout/playout_request.py | 69 ++++++++++ backend/api/rundown/__init__.py | 38 +----- .../rundown/{rundown.py => get_rundown.py} | 0 backend/api/rundown/rundown_request.py | 37 ++++++ backend/api/scheduler/__init__.py | 44 +------ backend/api/scheduler/scheduler_request.py | 49 +++++++ backend/pyproject.toml | 1 + 18 files changed, 322 insertions(+), 283 deletions(-) rename backend/api/init/{settings.py => client_settings.py} (100%) create mode 100644 backend/api/init/init_request.py rename backend/api/jobs/{actions.py => actions_request.py} (100%) rename backend/api/jobs/{jobs.py => jobs_request.py} (100%) rename backend/api/jobs/{send.py => send_request.py} (100%) create mode 100644 backend/api/order/order_request.py rename backend/api/order/{order.py => set_rundown_order.py} (100%) create mode 100644 backend/api/playout/playout_request.py rename backend/api/rundown/{rundown.py => get_rundown.py} (100%) create mode 100644 backend/api/rundown/rundown_request.py create mode 100644 backend/api/scheduler/scheduler_request.py diff --git a/backend/api/init/__init__.py b/backend/api/init/__init__.py index b9cd9a79..946eb035 100644 --- a/backend/api/init/__init__.py +++ b/backend/api/init/__init__.py @@ -1,104 +1,3 @@ -from typing import Any, Literal +__all__ = ["InitRequest"] -import fastapi -from pydantic import Field - -import nebula -from nebula.plugins.frontend import PluginItemModel, get_frontend_plugins -from nebula.settings import load_settings -from nebula.settings.common import LanguageCode -from server.context import ScopedEndpoint, server_context -from server.dependencies import CurrentUserOptional -from server.models import ResponseModel -from server.request import APIRequest - -from .settings import ClientSettingsModel, get_client_settings - - -class InitResponseModel(ResponseModel): - installed: Literal[True] | None = Field( - True, - title="Installed", - description="Is Nebula installed?", - ) - - motd: str | None = Field( - None, - title="Message of the day", - description="Server welcome string (displayed on login page)", - ) - - user: dict[str, Any] | None = Field( - None, - title="User data", - description="User data if user is logged in", - ) - - settings: ClientSettingsModel | None = Field( - None, - title="Client settings", - ) - - frontend_plugins: list[PluginItemModel] = Field( - default_factory=list, - title="Frontend plugins", - description="List of plugins available for the web frontend", - ) - - scoped_endpoints: list[ScopedEndpoint] = Field(default_factory=list) - - oauth2_options: list[dict[str, Any]] = Field( - default_factory=list, - title="OAuth2 options", - ) - - -class Request(APIRequest): - """Initial client request to ensure user is logged in. - - If a valid access token is provided, user data is returned, - in the result. - - Additionally (regadless the authorization), a message of the day - (motd) and OAuth2 options are returned. - """ - - name = "init" - title = "Login" - response_model = InitResponseModel - - async def handle( - self, - request: fastapi.Request, - user: CurrentUserOptional, - ) -> InitResponseModel: - default_motd = f"Nebula {nebula.__version__} @ {nebula.config.site_name}" - motd = nebula.config.motd or default_motd - - # Nebula is not installed. Frontend should display - # an error message or redirect to the installation page. - if not nebula.settings.installed: - await load_settings() - if not nebula.settings.installed: - return InitResponseModel(installed=False) - - # Not logged in. Only return motd and oauth2 options. - if user is None: - return InitResponseModel(motd=motd) - - # TODO: get preferred user language - lang: LanguageCode = user.language - - # Construct client settings - client_settings = await get_client_settings(lang) - client_settings.server_url = f"{request.url.scheme}://{request.url.netloc}" - plugins = get_frontend_plugins() - - return InitResponseModel( - installed=True, - motd=motd, - user=user.meta, - settings=client_settings, - frontend_plugins=plugins, - scoped_endpoints=server_context.scoped_endpoints, - ) +from .init_request import InitRequest diff --git a/backend/api/init/settings.py b/backend/api/init/client_settings.py similarity index 100% rename from backend/api/init/settings.py rename to backend/api/init/client_settings.py diff --git a/backend/api/init/init_request.py b/backend/api/init/init_request.py new file mode 100644 index 00000000..8b103778 --- /dev/null +++ b/backend/api/init/init_request.py @@ -0,0 +1,122 @@ +from typing import Annotated, Any + +import fastapi +from pydantic import Field + +import nebula +from nebula.plugins.frontend import PluginItemModel, get_frontend_plugins +from nebula.settings import load_settings +from nebula.settings.common import LanguageCode +from server.context import ScopedEndpoint, server_context +from server.dependencies import CurrentUserOptional +from server.models import ResponseModel +from server.request import APIRequest + +from .client_settings import ClientSettingsModel, get_client_settings + + +class InitResponseModel(ResponseModel): + installed: Annotated[ + bool | None, + Field( + title="Installed", + description="Is Nebula installed?", + ), + ] = True + + motd: Annotated[ + str | None, + Field( + title="Message of the day", + description="Server welcome string (displayed on login page)", + ), + ] = None + + user: Annotated[ + dict[str, Any] | None, + Field( + title="User data", + description="User data if user is logged in", + ), + ] = None + + settings: Annotated[ + ClientSettingsModel | None, + Field( + title="Client settings", + ), + ] = None + + frontend_plugins: Annotated[ + list[PluginItemModel] | None, + Field( + title="Frontend plugins", + description="List of plugins available for the web frontend", + ), + ] = None + + scoped_endpoints: Annotated[ + list[ScopedEndpoint] | None, + Field( + title="Scoped endpoints", + description="List of available scoped endpoints", + ), + ] = None + + oauth2_options: Annotated[ + list[dict[str, Any]] | None, + Field( + title="OAuth2 options", + ), + ] = None + + +class InitRequest(APIRequest): + """Initial client request to ensure user is logged in. + + If a valid access token is provided, user information + and Nebula settings are returned. If no access token is + provided, only the message of the day and OAuth2 options + are returned. + """ + + name = "init" + title = "Login" + response_model = InitResponseModel + + async def handle( + self, + request: fastapi.Request, + user: CurrentUserOptional, + ) -> InitResponseModel: + default_motd = f"Nebula {nebula.__version__} @ {nebula.config.site_name}" + motd = nebula.config.motd or default_motd + + # Nebula is not installed. Frontend should display + # an error message or redirect to the installation page. + if not nebula.settings.installed: + await load_settings() + if not nebula.settings.installed: + return InitResponseModel(installed=False) + + # Not logged in. Only return motd and oauth2 options. + # TODO: return oauth2 options + if user is None: + return InitResponseModel(motd=motd) + + # TODO: get preferred user language + lang: LanguageCode = user.language + + # Construct client settings + client_settings = await get_client_settings(lang) + client_settings.server_url = f"{request.url.scheme}://{request.url.netloc}" + plugins = get_frontend_plugins() + + return InitResponseModel( + installed=True, + motd=motd, + user=user.meta, + settings=client_settings, + frontend_plugins=plugins, + scoped_endpoints=server_context.scoped_endpoints, + ) diff --git a/backend/api/jobs/__init__.py b/backend/api/jobs/__init__.py index 99a76544..44441b22 100644 --- a/backend/api/jobs/__init__.py +++ b/backend/api/jobs/__init__.py @@ -1,5 +1,5 @@ __all__ = ["JobsRequest", "ActionsRequest", "SendRequest"] -from .actions import ActionsRequest -from .jobs import JobsRequest -from .send import SendRequest +from .actions_request import ActionsRequest +from .jobs_request import JobsRequest +from .send_request import SendRequest diff --git a/backend/api/jobs/actions.py b/backend/api/jobs/actions_request.py similarity index 100% rename from backend/api/jobs/actions.py rename to backend/api/jobs/actions_request.py diff --git a/backend/api/jobs/jobs.py b/backend/api/jobs/jobs_request.py similarity index 100% rename from backend/api/jobs/jobs.py rename to backend/api/jobs/jobs_request.py diff --git a/backend/api/jobs/send.py b/backend/api/jobs/send_request.py similarity index 100% rename from backend/api/jobs/send.py rename to backend/api/jobs/send_request.py diff --git a/backend/api/order/__init__.py b/backend/api/order/__init__.py index b1a0509e..1e9a90cb 100644 --- a/backend/api/order/__init__.py +++ b/backend/api/order/__init__.py @@ -1,31 +1,3 @@ -import nebula -from nebula.helpers.scheduling import bin_refresh -from server.dependencies import CurrentUser, RequestInitiator -from server.request import APIRequest +__all__ = ["OrderRequest"] -from .models import OrderRequestModel, OrderResponseModel -from .order import set_rundown_order - - -class Request(APIRequest): - """Set the order of items of a rundown""" - - name = "order" - title = "Order" - response_model = OrderResponseModel - - async def handle( - self, - request: OrderRequestModel, - user: CurrentUser, - initiator: RequestInitiator, - ) -> OrderResponseModel: - if not user.can("rundown_edit", request.id_channel): - raise nebula.ForbiddenException("You are not allowed to edit this rundown") - - result = await set_rundown_order(request, user) - nebula.log.info(f"Changed order in bins {result.affected_bins}", user=user.name) - - # Update bin duration - await bin_refresh(result.affected_bins, initiator=initiator, user=user) - return result +from .order_request import OrderRequest diff --git a/backend/api/order/order_request.py b/backend/api/order/order_request.py new file mode 100644 index 00000000..c1f5eb58 --- /dev/null +++ b/backend/api/order/order_request.py @@ -0,0 +1,31 @@ +import nebula +from nebula.helpers.scheduling import bin_refresh +from server.dependencies import CurrentUser, RequestInitiator +from server.request import APIRequest + +from .models import OrderRequestModel, OrderResponseModel +from .set_rundown_order import set_rundown_order + + +class OrderRequest(APIRequest): + """Set the order of items of a rundown""" + + name = "order" + title = "Order" + response_model = OrderResponseModel + + async def handle( + self, + request: OrderRequestModel, + user: CurrentUser, + initiator: RequestInitiator, + ) -> OrderResponseModel: + if not user.can("rundown_edit", request.id_channel): + raise nebula.ForbiddenException("You are not allowed to edit this rundown") + + result = await set_rundown_order(request, user) + nebula.log.info(f"Changed order in bins {result.affected_bins}", user=user.name) + + # Update bin duration + await bin_refresh(result.affected_bins, initiator=initiator, user=user) + return result diff --git a/backend/api/order/order.py b/backend/api/order/set_rundown_order.py similarity index 100% rename from backend/api/order/order.py rename to backend/api/order/set_rundown_order.py diff --git a/backend/api/playout/__init__.py b/backend/api/playout/__init__.py index 82ba3473..47d0613e 100644 --- a/backend/api/playout/__init__.py +++ b/backend/api/playout/__init__.py @@ -1,70 +1,3 @@ -# import httpx -import requests +__all__ = ["PlayoutRequest"] -import nebula -from server.dependencies import CurrentUser -from server.request import APIRequest - -from .models import PlayoutRequestModel, PlayoutResponseModel - - -class Request(APIRequest): - """Control a playout server""" - - name = "playout" - title = "Playout" - response_model = PlayoutResponseModel - - def handle( - self, - request: PlayoutRequestModel, - user: CurrentUser, - ) -> PlayoutResponseModel: - channel = nebula.settings.get_playout_channel(request.id_channel) - if not channel: - raise nebula.NotFoundException("Channel not found") - - # TODO: Check if user has access to this channel - - # For dummy engine, return empty response - if channel.engine == "dummy": - return PlayoutResponseModel(plugins=[]) - - # - # Make a request to the playout controller - # - - controller_url = f"http://{channel.controller_host}:{channel.controller_port}" - - # async with httpx.AsyncClient() as client: - # response = await client.post( - # f"{controller_url}/{request.action.value}", - # json=request.payload, - # timeout=4, - # ) - - # HTTPx stopped working for some reason, raising asyncio.CancelledError - # when trying to send a request. Using requests for now. - - response = requests.post( - f"{controller_url}/{request.action.value}", - json=request.payload, - timeout=4, - ) - - # - # Parse response and return - # - - try: - data = response.json() - except Exception as e: - nebula.log.error("Unable to parse response from playout controller") - raise nebula.NebulaException( - "Unable to parse response from playout controller" - ) from e - - if not response: - raise nebula.NebulaException(data.get("message", "Unknown error")) - - return PlayoutResponseModel(plugins=data.get("plugins")) +from .playout_request import PlayoutRequest diff --git a/backend/api/playout/playout_request.py b/backend/api/playout/playout_request.py new file mode 100644 index 00000000..fa88ab44 --- /dev/null +++ b/backend/api/playout/playout_request.py @@ -0,0 +1,69 @@ +import requests + +import nebula +from server.dependencies import CurrentUser +from server.request import APIRequest + +from .models import PlayoutRequestModel, PlayoutResponseModel + + +class PlayoutRequest(APIRequest): + """Control a playout server""" + + name = "playout" + title = "Playout" + response_model = PlayoutResponseModel + + def handle( + self, + request: PlayoutRequestModel, + user: CurrentUser, + ) -> PlayoutResponseModel: + channel = nebula.settings.get_playout_channel(request.id_channel) + if not channel: + raise nebula.NotFoundException("Channel not found") + + # TODO: Check if user has access to this channel + + # For dummy engine, return empty response + if channel.engine == "dummy": + return PlayoutResponseModel(plugins=[]) + + # + # Make a request to the playout controller + # + + controller_url = f"http://{channel.controller_host}:{channel.controller_port}" + + # async with httpx.AsyncClient() as client: + # response = await client.post( + # f"{controller_url}/{request.action.value}", + # json=request.payload, + # timeout=4, + # ) + + # HTTPx stopped working for some reason, raising asyncio.CancelledError + # when trying to send a request. Using requests for now. + + response = requests.post( + f"{controller_url}/{request.action.value}", + json=request.payload, + timeout=4, + ) + + # + # Parse response and return + # + + try: + data = response.json() + except Exception as e: + nebula.log.error("Unable to parse response from playout controller") + raise nebula.NebulaException( + "Unable to parse response from playout controller" + ) from e + + if not response: + raise nebula.NebulaException(data.get("message", "Unknown error")) + + return PlayoutResponseModel(plugins=data.get("plugins")) diff --git a/backend/api/rundown/__init__.py b/backend/api/rundown/__init__.py index 0e7d74f7..73985fe4 100644 --- a/backend/api/rundown/__init__.py +++ b/backend/api/rundown/__init__.py @@ -1,37 +1,3 @@ -import nebula -from server.dependencies import CurrentUser -from server.request import APIRequest +__all__ = ["RundownRequest"] -from .models import RundownRequestModel, RundownResponseModel -from .rundown import get_rundown - - -class Request(APIRequest): - """Get a rundown for the given day - - Date is specified in YYYY-mm-dd format and it takes in - account the channel broadcast start setting (usually 7:00) - - Rundown is a list of items that are scheduled to be broadcasted - in the given day. Items are ordered by their start time and contain - additional metadata such as scheduled time, actual broadcast time, - etc. During the broadcast, the actual time is updated to reflect - the real broadcast time. - - Additional non-playable items are also included in the rundown, - such as blocks, placeholders, loop markers and so on. - """ - - name = "rundown" - title = "Get rundown" - response_model = RundownResponseModel - - async def handle( - self, - request: RundownRequestModel, - user: CurrentUser, - ) -> RundownResponseModel: - if not user.can("rundown_view", request.id_channel): - raise nebula.ForbiddenException("You are not allowed to view this rundown") - - return await get_rundown(request) +from .rundown_request import RundownRequest diff --git a/backend/api/rundown/rundown.py b/backend/api/rundown/get_rundown.py similarity index 100% rename from backend/api/rundown/rundown.py rename to backend/api/rundown/get_rundown.py diff --git a/backend/api/rundown/rundown_request.py b/backend/api/rundown/rundown_request.py new file mode 100644 index 00000000..4c568331 --- /dev/null +++ b/backend/api/rundown/rundown_request.py @@ -0,0 +1,37 @@ +import nebula +from server.dependencies import CurrentUser +from server.request import APIRequest + +from .get_rundown import get_rundown +from .models import RundownRequestModel, RundownResponseModel + + +class RundownRequest(APIRequest): + """Retrieve the rundown for a specified channel and date. + + The rundown is a list of items scheduled for broadcast on a given day. + Items are ordered by their start time and include metadata such as + scheduled time and actual broadcast time. During the broadcast, the + actual time is updated to reflect the real broadcast time. + + The rundown also includes non-playable items like blocks, placeholders, + and loop markers. + + The date should be specified in the format YYYY-MM-DD, considering the + channel's start time as configured. If no date is specified, the current + date is used. + """ + + name = "rundown" + title = "Get rundown" + response_model = RundownResponseModel + + async def handle( + self, + request: RundownRequestModel, + user: CurrentUser, + ) -> RundownResponseModel: + if not user.can("rundown_view", request.id_channel): + raise nebula.ForbiddenException("You are not allowed to view this rundown") + + return await get_rundown(request) diff --git a/backend/api/scheduler/__init__.py b/backend/api/scheduler/__init__.py index 1e005423..ab527963 100644 --- a/backend/api/scheduler/__init__.py +++ b/backend/api/scheduler/__init__.py @@ -1,43 +1,3 @@ -import nebula -from nebula.helpers.scheduling import bin_refresh -from server.dependencies import CurrentUser, RequestInitiator -from server.request import APIRequest +__all__ = ["SchedulerRequest"] -from .models import SchedulerRequestModel, SchedulerResponseModel -from .scheduler import scheduler - - -class Request(APIRequest): - """Modify a channel schedule""" - - name = "scheduler" - title = "Scheduler" - response_model = SchedulerResponseModel - - async def handle( - self, - request: SchedulerRequestModel, - user: CurrentUser, - initiator: RequestInitiator, - ) -> SchedulerResponseModel: - if not user.can("scheduler_view", request.id_channel): - raise nebula.ForbiddenException("You are not allowed to view this channel") - - editable = user.can("scheduler_edit", request.id_channel) - result = await scheduler(request, editable, user=user) - - if result.affected_bins: - await bin_refresh( - result.affected_bins, - initiator=initiator, - user=user, - ) - - if result.affected_events: - await nebula.msg( - "objects_changed", - objects=result.affected_events, - object_type="event", - initiator=initiator, - ) - return result +from .scheduler_request import SchedulerRequest diff --git a/backend/api/scheduler/scheduler_request.py b/backend/api/scheduler/scheduler_request.py new file mode 100644 index 00000000..efe147d7 --- /dev/null +++ b/backend/api/scheduler/scheduler_request.py @@ -0,0 +1,49 @@ +import nebula +from nebula.helpers.scheduling import bin_refresh +from server.dependencies import CurrentUser, RequestInitiator +from server.request import APIRequest + +from .models import SchedulerRequestModel, SchedulerResponseModel +from .scheduler import scheduler + + +class SchedulerRequest(APIRequest): + """Retrieve or update the schedule for a channel + + This endpoint handles chanel macro-scheduling, + including the creation, modification, and deletion of playout events. + + Schedule is represented as a list of events, typically for one week. + """ + + name = "scheduler" + title = "Scheduler" + response_model = SchedulerResponseModel + + async def handle( + self, + request: SchedulerRequestModel, + user: CurrentUser, + initiator: RequestInitiator, + ) -> SchedulerResponseModel: + if not user.can("scheduler_view", request.id_channel): + raise nebula.ForbiddenException("You are not allowed to view this channel") + + editable = user.can("scheduler_edit", request.id_channel) + result = await scheduler(request, editable, user=user) + + if result.affected_bins: + await bin_refresh( + result.affected_bins, + initiator=initiator, + user=user, + ) + + if result.affected_events: + await nebula.msg( + "objects_changed", + objects=result.affected_events, + object_type="event", + initiator=initiator, + ) + return result diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 1e0d8b97..edc7ff5f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -83,6 +83,7 @@ ignore = [ "C901", # too complex. C'mon - this is a complex project "ANN101", # missing type annotation for self "ANN102", # missing type annotation for cls + "ASYNC110", # let us sleep ] [tool.mypy] From 5172a99f85a2d245a0e542417df3fe7376132822 Mon Sep 17 00:00:00 2001 From: Martastain Date: Wed, 16 Oct 2024 21:40:25 +0200 Subject: [PATCH 06/11] refactor: auth api module --- backend/api/auth/__init__.py | 5 ++ .../api/{auth.py => auth/login_request.py} | 80 +------------------ backend/api/auth/logout_request.py | 28 +++++++ backend/api/auth/set_password_request.py | 52 ++++++++++++ 4 files changed, 86 insertions(+), 79 deletions(-) create mode 100644 backend/api/auth/__init__.py rename backend/api/{auth.py => auth/login_request.py} (59%) create mode 100644 backend/api/auth/logout_request.py create mode 100644 backend/api/auth/set_password_request.py diff --git a/backend/api/auth/__init__.py b/backend/api/auth/__init__.py new file mode 100644 index 00000000..568786ed --- /dev/null +++ b/backend/api/auth/__init__.py @@ -0,0 +1,5 @@ +__all__ = ["LoginRequest", "LogoutRequest", "SetPasswordRequest"] + +from .login_request import LoginRequest +from .logout_request import LogoutRequest +from .set_password_request import SetPasswordRequest diff --git a/backend/api/auth.py b/backend/api/auth/login_request.py similarity index 59% rename from backend/api/auth.py rename to backend/api/auth/login_request.py index fa8a93b0..b90c245f 100644 --- a/backend/api/auth.py +++ b/backend/api/auth/login_request.py @@ -1,19 +1,13 @@ import time -from fastapi import Header, Request, Response +from fastapi import Request from pydantic import Field import nebula from server.clientinfo import get_real_ip -from server.dependencies import CurrentUser from server.models import RequestModel, ResponseModel from server.request import APIRequest from server.session import Session -from server.utils import parse_access_token - -# -# Models -# class LoginRequestModel(RequestModel): @@ -40,16 +34,6 @@ class LoginResponseModel(ResponseModel): ) -class PasswordRequestModel(RequestModel): - login: str | None = Field(None, title="Login", examples=["admin"]) - password: str = Field(..., title="Password", examples=["Password.123"]) - - -# -# Request -# - - async def check_failed_login(ip_address: str) -> None: banned_until = await nebula.redis.get("banned-ip-until", ip_address) if banned_until is None: @@ -121,65 +105,3 @@ async def handle( session = await Session.create(user, request) return LoginResponseModel(access_token=session.token) - - -class LogoutRequest(APIRequest): - """Log out the current user. - - This request will invalidate the access token used in the Authorization header. - """ - - name = "logout" - title = "Logout" - - async def handle(self, authorization: str | None = Header(None)) -> None: - if not authorization: - raise nebula.UnauthorizedException("No authorization header provided") - - access_token = parse_access_token(authorization) - if not access_token: - raise nebula.UnauthorizedException("Invalid authorization header provided") - - await Session.delete(access_token) - - raise nebula.UnauthorizedException("Logged out") - - -class SetPassword(APIRequest): - """Set a new password for the current (or a given) user. - - Normal users can only change their own password. - - In order to set a password for another user, - the current user must be an admin, otherwise a 403 error is returned. - """ - - name = "password" - title = "Set password" - - async def handle( - self, - request: PasswordRequestModel, - user: CurrentUser, - ) -> Response: - if request.login: - if not user.is_admin: - raise nebula.ForbiddenException( - "Only admin can change other user's password" - ) - query = "SELECT meta FROM users WHERE login = $1" - async for row in nebula.db.iterate(query, request.login): - target_user = nebula.User.from_row(row) - break - else: - raise nebula.NotFoundException(f"User {request.login} not found") - else: - target_user = user - - if len(request.password) < 8: - raise nebula.BadRequestException("Password is too short") - - target_user.set_password(request.password) - await target_user.save() - - return Response(status_code=204) diff --git a/backend/api/auth/logout_request.py b/backend/api/auth/logout_request.py new file mode 100644 index 00000000..d89e3442 --- /dev/null +++ b/backend/api/auth/logout_request.py @@ -0,0 +1,28 @@ +from fastapi import Header + +import nebula +from server.request import APIRequest +from server.session import Session +from server.utils import parse_access_token + + +class LogoutRequest(APIRequest): + """Log out the current user. + + This request will invalidate the access token used in the Authorization header. + """ + + name = "logout" + title = "Logout" + + async def handle(self, authorization: str | None = Header(None)) -> None: + if not authorization: + raise nebula.UnauthorizedException("No authorization header provided") + + access_token = parse_access_token(authorization) + if not access_token: + raise nebula.UnauthorizedException("Invalid authorization header provided") + + await Session.delete(access_token) + + raise nebula.UnauthorizedException("Logged out") diff --git a/backend/api/auth/set_password_request.py b/backend/api/auth/set_password_request.py new file mode 100644 index 00000000..adb7408f --- /dev/null +++ b/backend/api/auth/set_password_request.py @@ -0,0 +1,52 @@ +from fastapi import Response +from pydantic import Field + +import nebula +from server.dependencies import CurrentUser +from server.models import RequestModel +from server.request import APIRequest + + +class PasswordRequestModel(RequestModel): + login: str | None = Field(None, title="Login", examples=["admin"]) + password: str = Field(..., title="Password", examples=["Password.123"]) + + +class SetPasswordRequest(APIRequest): + """Set a new password for the current (or a given) user. + + Normal users can only change their own password. + + In order to set a password for another user, + the current user must be an admin, otherwise a 403 error is returned. + """ + + name = "password" + title = "Set password" + + async def handle( + self, + request: PasswordRequestModel, + user: CurrentUser, + ) -> Response: + if request.login: + if not user.is_admin: + raise nebula.ForbiddenException( + "Only admin can change other user's password" + ) + query = "SELECT meta FROM users WHERE login = $1" + async for row in nebula.db.iterate(query, request.login): + target_user = nebula.User.from_row(row) + break + else: + raise nebula.NotFoundException(f"User {request.login} not found") + else: + target_user = user + + if len(request.password) < 8: + raise nebula.BadRequestException("Password is too short") + + target_user.set_password(request.password) + await target_user.save() + + return Response(status_code=204) From 9a1647d9dcdbd68dd855bb0a753931b668090f89 Mon Sep 17 00:00:00 2001 From: Martastain Date: Wed, 16 Oct 2024 21:45:33 +0200 Subject: [PATCH 07/11] refactor: sessions api module --- .../invalidate_session_request.py} | 41 ++----------------- backend/api/sessions/list_sessions_request.py | 40 ++++++++++++++++++ backend/api/sessions/sessions.py | 4 ++ 3 files changed, 48 insertions(+), 37 deletions(-) rename backend/api/{sessions.py => sessions/invalidate_session_request.py} (51%) create mode 100644 backend/api/sessions/list_sessions_request.py create mode 100644 backend/api/sessions/sessions.py diff --git a/backend/api/sessions.py b/backend/api/sessions/invalidate_session_request.py similarity index 51% rename from backend/api/sessions.py rename to backend/api/sessions/invalidate_session_request.py index 55023a79..b7178a03 100644 --- a/backend/api/sessions.py +++ b/backend/api/sessions/invalidate_session_request.py @@ -4,47 +4,14 @@ from server.dependencies import CurrentUser from server.models import RequestModel from server.request import APIRequest -from server.session import Session, SessionModel +from server.session import Session -class SessionsRequest(RequestModel): - id_user: int = Query(..., examples=[1]) - - -class Sessions(APIRequest): - """List user sessions.""" - - name = "sessions" - title = "List sessions" - - async def handle( - self, - request: SessionsRequest, - user: CurrentUser, - ) -> list[SessionModel]: - id_user = request.id_user - - if id_user != user.id and (not user.is_admin): - raise nebula.ForbiddenException() - - result = [] - async for session in Session.list(): - if (id_user is not None) and (id_user != session.user["id"]): - continue - - if (not user.is_admin) and (id_user != session.user["id"]): - continue - - result.append(session) - - return result - - -class InvalidateSessionRequest(RequestModel): +class InvalidateSessionRequestModel(RequestModel): token: str = Query(...) -class InvalidateSession(APIRequest): +class InvalidateSessionRequest(APIRequest): """Invalidate a user session. This endpoint is used to invalidate an user session. It can be used @@ -58,7 +25,7 @@ class InvalidateSession(APIRequest): async def handle( self, - payload: InvalidateSessionRequest, + payload: InvalidateSessionRequestModel, user: CurrentUser, ) -> Response: session = await Session.check(payload.token) diff --git a/backend/api/sessions/list_sessions_request.py b/backend/api/sessions/list_sessions_request.py new file mode 100644 index 00000000..b0ba76d8 --- /dev/null +++ b/backend/api/sessions/list_sessions_request.py @@ -0,0 +1,40 @@ +from fastapi import Query + +import nebula +from server.dependencies import CurrentUser +from server.models import RequestModel +from server.request import APIRequest +from server.session import Session, SessionModel + + +class ListSessionsRequestModel(RequestModel): + id_user: int = Query(..., examples=[1]) + + +class ListSessionsRequest(APIRequest): + """List user sessions.""" + + name = "sessions" + title = "List sessions" + + async def handle( + self, + request: ListSessionsRequestModel, + user: CurrentUser, + ) -> list[SessionModel]: + id_user = request.id_user + + if id_user != user.id and (not user.is_admin): + raise nebula.ForbiddenException() + + result = [] + async for session in Session.list(): + if (id_user is not None) and (id_user != session.user["id"]): + continue + + if (not user.is_admin) and (id_user != session.user["id"]): + continue + + result.append(session) + + return result diff --git a/backend/api/sessions/sessions.py b/backend/api/sessions/sessions.py new file mode 100644 index 00000000..0b71b063 --- /dev/null +++ b/backend/api/sessions/sessions.py @@ -0,0 +1,4 @@ +__all__ = ["ListSessionsRequest", "InvalidateSessionRequest"] + +from .invalidate_session_request import InvalidateSessionRequest +from .list_sessions_request import ListSessionsRequest From ae0ba0371a5126b67b0b3a3a6d53f4bdd8bc0c53 Mon Sep 17 00:00:00 2001 From: Martastain Date: Wed, 16 Oct 2024 21:46:13 +0200 Subject: [PATCH 08/11] fix: missing sessions/__init__.py --- backend/api/sessions/{sessions.py => __init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/api/sessions/{sessions.py => __init__.py} (100%) diff --git a/backend/api/sessions/sessions.py b/backend/api/sessions/__init__.py similarity index 100% rename from backend/api/sessions/sessions.py rename to backend/api/sessions/__init__.py From 6f211b6d375a8b81a29eedca245ce76ecefc3dd3 Mon Sep 17 00:00:00 2001 From: Martastain Date: Wed, 16 Oct 2024 21:56:59 +0200 Subject: [PATCH 09/11] refactor: users api module --- backend/api/users.py | 118 ------------------------ backend/api/users/__init__.py | 4 + backend/api/users/list_users_request.py | 47 ++++++++++ backend/api/users/save_user_request.py | 52 +++++++++++ backend/api/users/user_model.py | 56 +++++++++++ 5 files changed, 159 insertions(+), 118 deletions(-) delete mode 100644 backend/api/users.py create mode 100644 backend/api/users/__init__.py create mode 100644 backend/api/users/list_users_request.py create mode 100644 backend/api/users/save_user_request.py create mode 100644 backend/api/users/user_model.py diff --git a/backend/api/users.py b/backend/api/users.py deleted file mode 100644 index 43fa3820..00000000 --- a/backend/api/users.py +++ /dev/null @@ -1,118 +0,0 @@ -from fastapi import Response -from pydantic import Field - -import nebula -from server.dependencies import CurrentUser -from server.models import ResponseModel -from server.request import APIRequest - - -class UserModel(ResponseModel): - id: int | None = Field(None, title="User ID") - login: str = Field(..., title="Login name") - full_name: str | None = Field(None, title="Full name") - email: str | None = Field(None, title="Email address") - is_admin: bool = Field(False, title="Is user an administrator") - is_limited: bool = Field(False, title="Is user limited") - local_network_only: bool = Field(False, title="Allow only local login") - password: str | None = Field(None, title="Password") - api_key: str | None = Field(None, title="API key") - - can_asset_view: bool | list[int] = Field(False) - can_asset_edit: bool | list[int] = Field(False) - can_scheduler_view: bool | list[int] = Field(False) - can_scheduler_edit: bool | list[int] = Field(False) - can_rundown_view: bool | list[int] = Field(False) - can_rundown_edit: bool | list[int] = Field( - False, - description="Use list of channel IDs for channel-specific rights", - ) - can_job_control: bool | list[int] = Field( - False, - description="Use list of action IDs to grant access to specific actions", - ) - can_mcr: bool | list[int] = Field(False) - - -class UserListResponseModel(ResponseModel): - """Response model for listing users""" - - users: list[UserModel] = Field(..., title="List of users") - - -class UserListRequest(APIRequest): - """Get a list of users""" - - name = "user_list" - title = "Get user list" - response_model = UserListResponseModel - - async def handle(self, user: CurrentUser) -> UserListResponseModel: - if not user.is_admin: - raise nebula.ForbiddenException("You are not allowed to list users") - - query = "SELECT meta FROM users ORDER BY login ASC" - - users = [] - async for row in nebula.db.iterate(query): - meta = {} - for key, value in row["meta"].items(): - if key == "api_key_preview": - continue - if key == "api_key": - meta[key] = row["meta"].get("api_key_preview", "*****") - elif key == "password": - continue - elif key.startswith("can/"): - meta[key.replace("can/", "can_")] = value - else: - meta[key] = value - - users.append(UserModel(**meta)) - - return UserListResponseModel(users=users) - - -class SaveUserRequest(APIRequest): - """Save user data""" - - name = "save_user" - title = "Save user data" - responses = [204, 201] - - async def handle(self, user: CurrentUser, payload: UserModel) -> Response: - new_user = payload.id is None - - if not user.is_admin: - raise nebula.ForbiddenException("You are not allowed to edit users") - - meta = payload.dict() - meta.pop("id", None) - - password = meta.pop("password", None) - api_key = meta.pop("api_key", None) - - for key, value in list(meta.items()): - if key.startswith("can_"): - meta[key.replace("can_", "can/")] = value - del meta[key] - - if new_user: - user = nebula.User.from_meta(meta) - else: - assert payload.id is not None, "This shoudn't happen" - user = await nebula.User.load(payload.id) - user.update(meta) - - if password: - user.set_password(password) - - if api_key: - user.set_api_key(api_key) - - await user.save() - - if new_user: - return Response(status_code=201) - else: - return Response(status_code=204) diff --git a/backend/api/users/__init__.py b/backend/api/users/__init__.py new file mode 100644 index 00000000..193ca19a --- /dev/null +++ b/backend/api/users/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["ListUsersRequest", "SaveUserRequest"] + +from .list_users_request import ListUsersRequest +from .save_user_request import SaveUserRequest diff --git a/backend/api/users/list_users_request.py b/backend/api/users/list_users_request.py new file mode 100644 index 00000000..ee3b69ab --- /dev/null +++ b/backend/api/users/list_users_request.py @@ -0,0 +1,47 @@ +from pydantic import Field + +import nebula +from server.dependencies import CurrentUser +from server.models import ResponseModel +from server.request import APIRequest + +from .user_model import UserModel + + +class ListUsersResponseModel(ResponseModel): + """Response model for listing users""" + + users: list[UserModel] = Field(..., title="List of users") + + +class ListUsersRequest(APIRequest): + """Get a list of users""" + + name = "user_list" + title = "Get user list" + response_model = ListUsersResponseModel + + async def handle(self, user: CurrentUser) -> ListUsersResponseModel: + if not user.is_admin: + raise nebula.ForbiddenException("You are not allowed to list users") + + query = "SELECT meta FROM users ORDER BY login ASC" + + users = [] + async for row in nebula.db.iterate(query): + meta = {} + for key, value in row["meta"].items(): + if key == "api_key_preview": + continue + if key == "api_key": + meta[key] = row["meta"].get("api_key_preview", "*****") + elif key == "password": + continue + elif key.startswith("can/"): + meta[key.replace("can/", "can_")] = value + else: + meta[key] = value + + users.append(UserModel(**meta)) + + return ListUsersResponseModel(users=users) diff --git a/backend/api/users/save_user_request.py b/backend/api/users/save_user_request.py new file mode 100644 index 00000000..f9444a05 --- /dev/null +++ b/backend/api/users/save_user_request.py @@ -0,0 +1,52 @@ +from fastapi import Response + +import nebula +from server.dependencies import CurrentUser +from server.request import APIRequest + +from .user_model import UserModel + + +class SaveUserRequest(APIRequest): + """Save user data""" + + name = "save_user" + title = "Save user data" + responses = [204, 201] + + async def handle(self, user: CurrentUser, payload: UserModel) -> Response: + new_user = payload.id is None + + if not user.is_admin: + raise nebula.ForbiddenException("You are not allowed to edit users") + + meta = payload.dict() + meta.pop("id", None) + + password = meta.pop("password", None) + api_key = meta.pop("api_key", None) + + for key, value in list(meta.items()): + if key.startswith("can_"): + meta[key.replace("can_", "can/")] = value + del meta[key] + + if new_user: + user = nebula.User.from_meta(meta) + else: + assert payload.id is not None, "This shoudn't happen" + user = await nebula.User.load(payload.id) + user.update(meta) + + if password: + user.set_password(password) + + if api_key: + user.set_api_key(api_key) + + await user.save() + + if new_user: + return Response(status_code=201) + else: + return Response(status_code=204) diff --git a/backend/api/users/user_model.py b/backend/api/users/user_model.py new file mode 100644 index 00000000..dbad3bb5 --- /dev/null +++ b/backend/api/users/user_model.py @@ -0,0 +1,56 @@ +from pydantic import Field + +from server.models import ResponseModel + + +class UserModel(ResponseModel): + id: int | None = Field(None, title="User ID") + login: str = Field(..., title="Login name") + full_name: str | None = Field(None, title="Full name") + email: str | None = Field(None, title="Email address") + is_admin: bool = Field(False, title="Is user an administrator") + is_limited: bool = Field(False, title="Is user limited") + local_network_only: bool = Field(False, title="Allow only local login") + password: str | None = Field(None, title="Password") + api_key: str | None = Field(None, title="API key") + + can_asset_view: bool | list[int] = Field( + False, + title="Can view assets", + description="List of folder IDs user can view. Use 'true' for all folders", + ) + can_asset_edit: bool | list[int] = Field( + False, + title="Can asset edit", + description="List of folder IDs user can edit. Use 'true' for all folders", + ) + can_scheduler_view: bool | list[int] = Field( + False, + title="Can view scheduler", + description="List of channel IDs user can view. Use 'true' for all channels", + ) + can_scheduler_edit: bool | list[int] = Field( + False, + title="Can edit scheduler", + description="List of channel IDs user can edit. Use 'true' for all channels", + ) + can_rundown_view: bool | list[int] = Field( + False, + title="Can view rundown", + description="List of channel IDs user can view. Use 'true' for all channels", + ) + can_rundown_edit: bool | list[int] = Field( + False, + title="Can edit rundown", + description="List of channel IDs user can edit. Use 'true' for all channels", + ) + can_job_control: bool | list[int] = Field( + False, + title="Can control jobs", + description="Use list of action IDs to grant access to specific actions", + ) + can_mcr: bool | list[int] = Field( + False, + title="Can control playout", + description="List of channel IDs user can control", + ) From b9200e655bf83d420495134f8c4234d2309f0177 Mon Sep 17 00:00:00 2001 From: Martastain Date: Thu, 17 Oct 2024 13:01:59 +0200 Subject: [PATCH 10/11] chore: clean-up --- backend/api/users/user_model.py | 8 +++---- backend/pyproject.toml | 38 +++++++++++++-------------------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/backend/api/users/user_model.py b/backend/api/users/user_model.py index dbad3bb5..49ee5268 100644 --- a/backend/api/users/user_model.py +++ b/backend/api/users/user_model.py @@ -7,10 +7,10 @@ class UserModel(ResponseModel): id: int | None = Field(None, title="User ID") login: str = Field(..., title="Login name") full_name: str | None = Field(None, title="Full name") - email: str | None = Field(None, title="Email address") - is_admin: bool = Field(False, title="Is user an administrator") - is_limited: bool = Field(False, title="Is user limited") - local_network_only: bool = Field(False, title="Allow only local login") + email: str | None = Field(None, title="Email") + is_admin: bool = Field(False, title="Is administrator") + is_limited: bool = Field(False, title="Is limited user") + local_network_only: bool = Field(False, title="Allow local login only") password: str | None = Field(None, title="Password") api_key: str | None = Field(None, title="API key") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index edc7ff5f..8018ec9a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,11 +4,6 @@ version = "6.0.7" description = "Open source broadcast automation system" authors = ["Nebula Broadcast "] -[tool.poetry.group.dev.dependencies] -types-requests = "^2.31.0.20240311" -types-aiofiles = "^23.2.0.20240311" -asyncpg-stubs = "^0.29.1" - [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" @@ -36,13 +31,16 @@ requests = "^2.32.3" rich = "^13.8.0" shortuuid = "^1.0.12" user-agents = "^2.2.0" -uvicorn = {extras = ["standard"], version = "0.31.0"} +uvicorn = { extras = ["standard"], version = "0.31.0" } -[tool.poetry.dev-dependencies] +[tool.poetry.group.dev.dependencies] +asyncpg-stubs = "^0.29.1" mypy = "^1.11" pytest = "^8.0" pytest-asyncio = "^0.20.3" ruff = "^0.6.8" +types-aiofiles = "^23.2.0.20240311" +types-requests = "^2.31.0.20240311" # # Tools @@ -71,25 +69,23 @@ select = [ "ASYNC", # flake8-async "SIM", # flake8-simplify "ISC", # flake8-implicit-str-concat - # "ANN", # flake8-annotations - # "N", # pep8-naming - # "D", # pydocstyle - # "S", # flake8-bandit + # "ANN", # flake8-annotations + # "N", # pep8-naming + # "D", # pydocstyle + # "S", # flake8-bandit ] ignore = [ "ISC001", - "B008", # do not perform function calls in argument defaults - "C901", # too complex. C'mon - this is a complex project - "ANN101", # missing type annotation for self - "ANN102", # missing type annotation for cls + "B008", # do not perform function calls in argument defaults + "C901", # too complex. C'mon - this is a complex project + "ANN101", # missing type annotation for self + "ANN102", # missing type annotation for cls "ASYNC110", # let us sleep ] [tool.mypy] -plugins = [ - "pydantic.mypy" -] +plugins = ["pydantic.mypy"] #follow_imports = "silent" #strict = true @@ -104,11 +100,7 @@ strict_optional = true exclude = "tests/|venv/" [[tool.mypy.overrides]] -module = [ - "nxtools", - "user_agents", - "tomllib" -] +module = ["nxtools", "user_agents", "tomllib"] ignore_errors = true follow_imports = "skip" ignore_missing_imports = true From bb2e0f2e69274ed146b85bf2e6bd29c14afb5053 Mon Sep 17 00:00:00 2001 From: Martastain Date: Thu, 17 Oct 2024 22:20:01 +0200 Subject: [PATCH 11/11] fix: imporoved menu highlighting --- frontend/src/components/ContextMenu.jsx | 6 +++--- frontend/src/components/Dropdown.jsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/ContextMenu.jsx b/frontend/src/components/ContextMenu.jsx index 4b39828a..18c06767 100644 --- a/frontend/src/components/ContextMenu.jsx +++ b/frontend/src/components/ContextMenu.jsx @@ -6,9 +6,9 @@ const ContextMenuWrapper = styled.div` position: fixed; display: inline-block; - background-color: var(--color-surface-04); + background-color: var(--color-surface-02); min-width: 100px; - box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); + box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.4); z-index: 1; hr { @@ -26,7 +26,7 @@ const ContextMenuWrapper = styled.div` padding: 20px 10px; &:hover { - background-color: var(--color-surface-03); + background-color: var(--color-surface-04); } &:active, diff --git a/frontend/src/components/Dropdown.jsx b/frontend/src/components/Dropdown.jsx index a5bd6368..fdf0a0ca 100644 --- a/frontend/src/components/Dropdown.jsx +++ b/frontend/src/components/Dropdown.jsx @@ -9,9 +9,9 @@ const DropdownContainer = styled.div` .dropdown-content { display: none; position: absolute; - background-color: var(--color-surface-04); + background-color: var(--color-surface-02); min-width: 100px; - box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); + box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.4); z-index: 1; hr { @@ -29,7 +29,7 @@ const DropdownContainer = styled.div` padding: 25px 8px; &:hover { - background-color: var(--color-surface-03); + background-color: var(--color-surface-04); } &:active,