diff --git a/aaa/models/user.py b/aaa/models/user.py index ea6a6bffe447828f4cd7b2e851d9ddaa8e26302d..e1d8c8469eeb370b6bf2f187c1069a8eb499583f 100644 --- a/aaa/models/user.py +++ b/aaa/models/user.py @@ -23,6 +23,7 @@ from noc.core.model.base import NOCModel from noc.core.model.decorator import on_delete_check from noc.core.translation import ugettext as _ from noc.settings import LANGUAGES +from noc.main.models.avatar import Avatar from .group import Group id_lock = Lock() @@ -181,3 +182,28 @@ class User(NOCModel): ts = ts or datetime.datetime.now() self.last_login = ts self.save(update_fields=["last_login"]) + + @property + def avatar_url(self) -> Optional[str]: + """ + Get user's avatar URL + :return: + """ + if not Avatar.objects.filter(user_id=str(self.id)).only("user_id").first(): + return None + return f"/api/ui/avatar/{self.id}" + + @property + def avatar_label(self) -> Optional[str]: + """ + Get avatar's textual label + :return: + """ + r = [] + if self.first_name: + r += [self.first_name[0].upper()] + if self.last_name: + r += [self.last_name[0].upper()] + if not r: + r += [self.username[:1].upper()] + return "".join(r) diff --git a/config.py b/config.py index cc4285e031a1eb6e736b22e17545d35f392586b2..8455ebdaf3d55a6f057728198961e1d7d7a94520 100644 --- a/config.py +++ b/config.py @@ -647,6 +647,9 @@ class Config(BaseConfig): macdb_window = IntParameter(default=4 * 86400) enable_remote_system_last_extract_info = BooleanParameter(default=False) + class ui(ConfigSection): + max_avatar_size = BytesParameter(default="256K") + class datasource(ConfigSection): chunk_size = IntParameter(default=1000) max_threads = IntParameter(default=10) diff --git a/core/mime.py b/core/mime.py new file mode 100644 index 0000000000000000000000000000000000000000..193e532dcc541ef4189de9a1d2bf57ef6fb7fa2f --- /dev/null +++ b/core/mime.py @@ -0,0 +1,45 @@ +# ---------------------------------------------------------------------- +# MIME Content Types +# ---------------------------------------------------------------------- +# Copyright (C) 2007-2021 The NOC Project +# See LICENSE for details +# ---------------------------------------------------------------------- + +# Python modules +from enum import IntEnum +from typing import Optional + + +class ContentType(IntEnum): + # Application + OCTET_STREAM = 0 + # Image + JPEG = 100 + GIF = 101 + PNG = 102 + + @property + def content_type(self) -> Optional[str]: + return _CONTENT_TYPE.get(self.value) + + @property + def is_image(self): + return self.value in _IMAGE_TYPE + + @classmethod + def from_content_type(cls, ct: str) -> Optional["ContentType"]: + return _R_CONTENT_TYPE.get(ct) + + +_IMAGE_TYPE = {ContentType.JPEG.value, ContentType.GIF.value, ContentType.PNG.value} + +_CONTENT_TYPE = { + # Application types + ContentType.OCTET_STREAM.value: "application/octet-stream", + # Images + ContentType.JPEG.value: "image/jpeg", + ContentType.GIF.value: "image/gif", + ContentType.PNG.value: "image/png", +} + +_R_CONTENT_TYPE = {_CONTENT_TYPE[ct]: ContentType(ct) for ct in _CONTENT_TYPE} diff --git a/docs/en/docs/admin/reference/config/ui.md b/docs/en/docs/admin/reference/config/ui.md new file mode 100644 index 0000000000000000000000000000000000000000..5a356dd3a7121506138967f84eb1cb5e8f685cd9 --- /dev/null +++ b/docs/en/docs/admin/reference/config/ui.md @@ -0,0 +1,14 @@ +# [ui] section + +[ui](../services/ui.md) service configuration + +## max_avatar_size + +Maximum allowed avatar image size, in bytes. + +| | | +| -------------- | ------------------------ | +| Default value | `256K` | +| YAML Path | `ui.max_avatar_size` | +| Key-Value Path | `ui/max_avatar_size` | +| Environment | `NOC_UI_MAX_AVATAR_SIZE` | diff --git a/docs/en/docs/admin/reference/services/ui.md b/docs/en/docs/admin/reference/services/ui.md new file mode 100644 index 0000000000000000000000000000000000000000..40130c798c11158f3e781e7c00d645a9d6aef67f --- /dev/null +++ b/docs/en/docs/admin/reference/services/ui.md @@ -0,0 +1 @@ +# ui service diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index 6ad0d9a96418f60b2a9f89c3005985aa74926d56..34845033d70f1300da6c5c80307eb84972fc157d 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -1316,6 +1316,7 @@ nav: - tgsender: admin/reference/services/tgsender.md - trapcollector: admin/reference/services/trapcollector.md - web: admin/reference/services/web.md + - ui: admin/reference/services/ui.md - Configuration: - Configuration System Overview: admin/reference/config/index.md - Date and Time Formats: admin/reference/datetime-format.md @@ -1383,6 +1384,7 @@ nav: - "[threadpool] section": admin/reference/config/threadpool.md - "[traceback] section": admin/reference/config/traceback.md - "[trapcollector] section": admin/reference/config/trapcollector.md + - "[ui] section": admin/reference/config/ui.md - "[web] section": admin/reference/config/web.md - Discovery: - Box Discovery: diff --git a/main/models/avatar.py b/main/models/avatar.py new file mode 100644 index 0000000000000000000000000000000000000000..2c17c7ba3b8bd30cadd781168c5be7805bdbf7b9 --- /dev/null +++ b/main/models/avatar.py @@ -0,0 +1,44 @@ +# ---------------------------------------------------------------------- +# Avatar model +# ---------------------------------------------------------------------- +# Copyright (C) 2007-2021 The NOC Project +# See LICENSE for details +# ---------------------------------------------------------------------- + +# Third-party modules +from mongoengine.document import Document +from mongoengine.fields import StringField, BinaryField, IntField +from mongoengine.errors import ValidationError + +# NOC modules +from noc.config import config +from noc.core.mime import ContentType + + +def validate_content_type(value: int) -> None: + # Check is valid content type + try: + ct = ContentType(value) + except ValueError as e: + raise ValidationError(str(e)) + # Check content type is image + if not ct.is_image: + raise ValidationError("Image is required") + + +class Avatar(Document): + meta = {"collection": "avatars", "strict": False, "auto_create_index": False} + + user_id = StringField(primary_key=True) + content_type = IntField(validation=validate_content_type) + data = BinaryField(max_bytes=config.ui.max_avatar_size) + + def __str__(self) -> str: + return self.user_id + + def get_content_type(self) -> str: + """ + Return content-type string + :return: + """ + return ContentType(self.content_type).content_type diff --git a/models.py b/models.py index 68ec97178a84e213430a8a4315cad180b957e10c..dbcdc555b21daab4c3cb3f3c826fa6612c771e92 100644 --- a/models.py +++ b/models.py @@ -90,6 +90,7 @@ _MODELS = { # main models "main.APIToken": "noc.main.models.apitoken.APIToken", "main.AuditTrail": "noc.main.models.audittrail.AuditTrail", + "main.Avatar": "noc.main.models.avatar.Avatar", "main.Checkpoint": "noc.main.models.checkpoint.Checkpoint", "main.CHPolicy": "noc.main.models.chpolicy.CHPolicy", "main.CronTab": "noc.main.models.crontab.CronTab", diff --git a/services/ui/models/me.py b/services/ui/models/me.py index 480542e56ce7b742a4da7c47470d5a31b1ce7fb7..3ef87432639518652095690a2cb146252159650e 100644 --- a/services/ui/models/me.py +++ b/services/ui/models/me.py @@ -29,3 +29,5 @@ class MeResponse(BaseModel): email: Optional[str] groups: List[GroupItem] language: str + avatar_url: Optional[str] + avatar_label: str diff --git a/services/ui/models/status.py b/services/ui/models/status.py new file mode 100644 index 0000000000000000000000000000000000000000..76b837b313a1c301f8f2bf467a9848ffba59bcb0 --- /dev/null +++ b/services/ui/models/status.py @@ -0,0 +1,21 @@ +# ---------------------------------------------------------------------- +# StatusResponse +# ---------------------------------------------------------------------- +# Copyright (C) 2007-2021 The NOC Project +# See LICENSE for details +# ---------------------------------------------------------------------- + +# Python modules +from typing import Optional, List + +# Third-party modules +from pydantic import BaseModel + + +class ErrorItem(BaseModel): + message: str + + +class StatusResponse(BaseModel): + status: bool + errors: Optional[List[ErrorItem]] diff --git a/services/ui/paths/avatar.py b/services/ui/paths/avatar.py new file mode 100644 index 0000000000000000000000000000000000000000..3558b69e7bfb88957ec5522f0aeb3a08c7a35caa --- /dev/null +++ b/services/ui/paths/avatar.py @@ -0,0 +1,65 @@ +# ---------------------------------------------------------------------- +# /api/ui/avatar endpoint +# ---------------------------------------------------------------------- +# Copyright (C) 2007-2021 The NOC Project +# See LICENSE for details +# ---------------------------------------------------------------------- + +# Third-party modules +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Response + +# NOC modules +from noc.config import config +from noc.aaa.models.user import User +from noc.core.service.deps.user import get_current_user +from noc.main.models.avatar import Avatar +from noc.core.comp import smart_bytes +from noc.core.ioloop.util import run_sync +from noc.core.mime import ContentType +from ..models.status import StatusResponse + +router = APIRouter() + + +@router.get("/api/ui/avatar/{user_id}", tags=["ui", "avatar"]) +def get_avatar(user_id: str, _: User = Depends(get_current_user)): + avatar = Avatar.objects.filter(user_id=str(user_id)).first() + if not avatar: + raise HTTPException(status_code=404) + return Response( + content=avatar.data, + media_type=avatar.get_content_type(), + ) + + +@router.post("/api/ui/avatar", response_model=StatusResponse, tags=["ui", "avatar"]) +def save_avatar(user: User = Depends(get_current_user), image: UploadFile = File(...)): + async def read_file() -> bytes: + return smart_bytes(await image.read(config.ui.max_avatar_size + 1)) + + data = run_sync(read_file) + if len(data) > config.ui.max_avatar_size: + raise HTTPException(status_code=413) + content_type = ContentType.from_content_type(image.content_type) + if content_type is None: + raise HTTPException(status_code=421) + avatar = Avatar.objects.filter(user_id=str(user.id)).first() + if avatar: + # Update + avatar.data = data + avatar.content_type = content_type + else: + # Create + avatar = Avatar(user_id=str(user.id), data=data, content_type=content_type) + avatar.save() + return StatusResponse(status=True) + + +@router.delete("/api/ui/avatar", response_model=StatusResponse, tags=["ui", "avatar"]) +def delete_avatar( + user: User = Depends(get_current_user), +): + avatar = Avatar.objects.filter(user_id=str(user.id)).first() + if avatar: + avatar.delete() + return StatusResponse(status=True) diff --git a/services/ui/paths/me.py b/services/ui/paths/me.py index 483634e86ade1b05b3383bf3f1f35ba3a51c7432..439ebfdaac3614454318ed9a1738a4b272ded881 100644 --- a/services/ui/paths/me.py +++ b/services/ui/paths/me.py @@ -27,4 +27,6 @@ def get_me(user: User = Depends(get_current_user)): email=user.email or None, groups=[GroupItem(id=str(g.id), name=g.name) for g in user.groups.all()], language=user.preferred_language or config.language, + avatar_url=user.avatar_url, + avatar_label=user.avatar_label, ) diff --git a/services/ui/service.py b/services/ui/service.py index f54405f6ff9141adf8a924068ff3c31602a37be0..61835830d5235e113177e372a4b7589957b54a8a 100755 --- a/services/ui/service.py +++ b/services/ui/service.py @@ -16,6 +16,7 @@ class UIService(FastAPIService): if config.features.traefik: traefik_backend = "ui" traefik_frontend_rule = "PathPrefix:/api/ui" + use_mongo = True if __name__ == "__main__": diff --git a/tests/test_mime.py b/tests/test_mime.py new file mode 100644 index 0000000000000000000000000000000000000000..4c7d69ecbb4875cc3b14324d7b78579073dbc605 --- /dev/null +++ b/tests/test_mime.py @@ -0,0 +1,25 @@ +# ---------------------------------------------------------------------- +# noc.core.mime tests +# ---------------------------------------------------------------------- +# Copyright (C) 2007-2021 The NOC Project +# See LICENSE for details +# ---------------------------------------------------------------------- + +# Third-party modules +import pytest + +# NOC modules +from noc.core.mime import ContentType + + +IMAGE_TYPES = {ContentType.JPEG.value, ContentType.GIF.value, ContentType.PNG.value} + + +@pytest.mark.parametrize("ct", list(ContentType)) +def test_content_type(ct: ContentType): + assert ct.content_type is not None + + +@pytest.mark.parametrize("ct", list(ContentType)) +def test_is_image(ct: ContentType): + assert ct.is_image is (ct.value in IMAGE_TYPES)