Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🔗[Sync] Develop to permission matrix #2101

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions forms-flow-api/migrations/versions/77d8b68e6c1f_users_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Users table

Revision ID: 77d8b68e6c1f
Revises: f1599a5bd658
Create Date: 2024-05-30 16:01:37.273907

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = '77d8b68e6c1f'
down_revision = 'f1599a5bd658'
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_name', sa.String(length=50), nullable=False),
sa.Column('default_filter', sa.Integer(), nullable=True),
sa.Column('locale', sa.String(), nullable=True, comment='language code'),
sa.Column('tenant', sa.String(), nullable=True, comment='tenant key'),
sa.Column('created', sa.DateTime(), nullable=False),
sa.Column('modified', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.String(), nullable=False),
sa.Column('modified_by', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['default_filter'], ['filter.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_name', 'tenant', name='uq_tenant_user_name')
)
op.alter_column('themes', 'logo_type',
existing_type=sa.VARCHAR(length=100),
type_=sa.String(length=50),
existing_nullable=False)
op.alter_column('themes', 'logo_data',
existing_type=sa.VARCHAR(),
comment='logo_data contain a base64 or a URL.',
existing_nullable=False)
op.alter_column('themes', 'theme',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment='Json data',
existing_nullable=False)
op.create_unique_constraint('uq_tenant', 'themes', ['tenant'])
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint('uq_tenant', 'themes', type_='unique')
op.alter_column('themes', 'theme',
existing_type=postgresql.JSON(astext_type=sa.Text()),
comment=None,
existing_comment='Json data',
existing_nullable=False)
op.alter_column('themes', 'logo_data',
existing_type=sa.VARCHAR(),
comment=None,
existing_comment='logo_data contain a base64 or a URL.',
existing_nullable=False)
op.alter_column('themes', 'logo_type',
existing_type=sa.String(length=50),
type_=sa.VARCHAR(length=100),
existing_nullable=False)
op.drop_table('user')
# ### end Alembic commands ###
2 changes: 2 additions & 0 deletions forms-flow-api/src/formsflow_api/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .form_process_mapper import FormProcessMapper
from .process import Process, ProcessStatus, ProcessType
from .theme import Themes
from .user import User

__all__ = [
"db",
Expand All @@ -28,4 +29,5 @@
"ProcessType",
"ProcessStatus",
"Themes",
"User",
]
5 changes: 3 additions & 2 deletions forms-flow-api/src/formsflow_api/models/theme.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""This manages theme Database Models."""

from sqlalchemy import JSON
from sqlalchemy import JSON, UniqueConstraint

from .audit_mixin import AuditDateTimeMixin, AuditUserMixin
from .base_model import BaseModel
Expand All @@ -18,7 +18,8 @@ class Themes(AuditDateTimeMixin, AuditUserMixin, BaseModel, db.Model):
)
application_title = db.Column(db.String(50), nullable=False)
theme = db.Column(JSON, nullable=False, comment="Json data")
tenant = db.Column(db.String(20), nullable=True, unique=True)
tenant = db.Column(db.String(20), nullable=True)
__table_args__ = (UniqueConstraint("tenant", name="uq_tenant"),)

@classmethod
def create_theme(cls, theme_info: dict):
Expand Down
70 changes: 70 additions & 0 deletions forms-flow-api/src/formsflow_api/models/user.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""This manages User Database Models."""

from flask_sqlalchemy.query import Query
from formsflow_api_utils.utils.user_context import UserContext, user_context
from sqlalchemy import UniqueConstraint

from .audit_mixin import AuditDateTimeMixin, AuditUserMixin
from .base_model import BaseModel
from .db import db


class User(AuditDateTimeMixin, AuditUserMixin, BaseModel, db.Model):
"""This class manages user information."""

id = db.Column(db.Integer, primary_key=True)
user_name = db.Column(db.String(50), nullable=False)
default_filter = db.Column(
db.Integer, db.ForeignKey("filter.id", ondelete="SET NULL"), nullable=True
)
locale = db.Column(db.String(), nullable=True, comment="language code")
tenant = db.Column(db.String(), nullable=True, comment="tenant key")
__table_args__ = (
UniqueConstraint("user_name", "tenant", name="uq_tenant_user_name"),
)

@classmethod
def create_user(cls, user_data: dict):
"""Create new user."""
assert user_data is not None
user = cls()
user.created_by = user_data.get("created_by")
user.user_name = user_data.get("user_name")
user.locale = user_data.get("locale")
user.tenant = user_data.get("tenant")
user.default_filter = user_data.get("default_filter")
user.save()
return user

def update(self, user_data: dict):
"""Update user data."""
self.update_from_dict(
[
"locale",
"tenant",
"default_filter",
],
user_data,
)
self.commit()

@classmethod
@user_context
def tenant_authorization(cls, query: Query, **kwargs):
"""Modifies the query to include tenant check if needed."""
tenant_auth_query: Query = query
user: UserContext = kwargs["user"]
tenant_key: str = user.tenant_key
if not isinstance(query, Query):
raise TypeError("Query object must be of type Query")
if tenant_key is not None:
tenant_auth_query = tenant_auth_query.filter(cls.tenant == tenant_key)
return tenant_auth_query

@classmethod
def get_user_by_user_name(cls, user_name: str = None):
"""Find user data by username."""
assert user_name is not None
query = cls.query.filter(cls.user_name == user_name)
query = cls.tenant_authorization(query)
return query.one_or_none()
10 changes: 9 additions & 1 deletion forms-flow-api/src/formsflow_api/resources/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@
},
)

filter_response_with_default_filter = API.model(
"FilterResponse",
{
"filters": fields.List(fields.Nested(filter_response)),
"defaultFilter": fields.String(description="Default filter"),
},
)


@cors_preflight("GET, POST, OPTIONS")
@API.route("", methods=["GET", "POST", "OPTIONS"])
Expand Down Expand Up @@ -158,7 +166,7 @@ class UsersFilterList(Resource):
200: "OK:- Successful request.",
403: "FORBIDDEN:- Permission denied",
},
model=[filter_response],
model=filter_response_with_default_filter,
)
def get():
"""
Expand Down
26 changes: 26 additions & 0 deletions forms-flow-api/src/formsflow_api/resources/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from flask_restx import Namespace, Resource, fields
from formsflow_api_utils.utils import (
ADMIN_GROUP,
REVIEWER_GROUP,
auth,
cors_preflight,
profiletime,
Expand All @@ -15,6 +16,7 @@
TenantUserAddSchema,
UserlocaleReqSchema,
UserPermissionUpdateSchema,
UserSchema,
UsersListSchema,
)
from formsflow_api.services import KeycloakAdminAPIService, UserService
Expand Down Expand Up @@ -67,6 +69,7 @@
)

locale_put_model = API.model("Locale", {"locale": fields.String()})
default_filter_model = API.model("DefaulFilter", {"defaultFilter": fields.String()})


@cors_preflight("PUT, OPTIONS")
Expand Down Expand Up @@ -115,6 +118,29 @@ def put(self) -> dict:
response = self.client.update_request(url_path=f"users/{user['id']}", data=user)
if response is None:
return {"message": "User not found"}, HTTPStatus.NOT_FOUND
# Capture "locale" changes in user table
UserService.update_user_data({"locale": dict_data["locale"]})
return response, HTTPStatus.OK


@cors_preflight("POST, OPTIONS")
@API.route("/default-filter", methods=["OPTIONS", "POST"])
class UserDefaultFilter(Resource):
"""Resource to create or update user's default filter."""

@staticmethod
@auth.has_one_of_roles([ADMIN_GROUP, REVIEWER_GROUP])
@profiletime
@API.doc(body=default_filter_model)
@API.response(200, "OK:- Successful request.")
@API.response(
400,
"BAD_REQUEST:- Invalid request.",
)
def post():
"""Update the user's default task filter."""
data = UserSchema().load(request.get_json())
response = UserService().update_user_data(data=data)
return response, HTTPStatus.OK


Expand Down
1 change: 1 addition & 0 deletions forms-flow-api/src/formsflow_api/schemas/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
TenantUserAddSchema,
UserlocaleReqSchema,
UserPermissionUpdateSchema,
UserSchema,
UsersListSchema,
)

Expand Down
3 changes: 2 additions & 1 deletion forms-flow-api/src/formsflow_api/schemas/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

import json

from marshmallow import EXCLUDE, Schema, fields, validates
from formsflow_api_utils.exceptions import BusinessException
from marshmallow import EXCLUDE, Schema, fields, validates

from formsflow_api.constants import BusinessErrorCode
from formsflow_api.models import FormProcessMapper

Expand Down
14 changes: 14 additions & 0 deletions forms-flow-api/src/formsflow_api/schemas/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,17 @@ class Meta: # pylint: disable=too-few-public-methods

user = fields.Str(data_key="user", required=True)
roles = fields.List(fields.Nested(AddUserRoleSchema))


class UserSchema(Schema):
"""Schema for user data."""

class Meta: # pylint: disable=too-few-public-methods
"""Exclude unknown fields in the deserialized output."""

unknown = EXCLUDE

default_filter = fields.Int(data_key="defaultFilter", allow_none=True)
locale = fields.Str(data_key="locale")
user_name = fields.Str(data_key="userName", dump_only=True)
# tenant = fields.Str(data_key="tenantKey", dump_only=True)
8 changes: 6 additions & 2 deletions forms-flow-api/src/formsflow_api/services/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from formsflow_api_utils.utils.user_context import UserContext, user_context

from formsflow_api.constants import BusinessErrorCode
from formsflow_api.models import Filter
from formsflow_api.models import Filter, User
from formsflow_api.schemas import FilterSchema

filter_schema = FilterSchema()
Expand Down Expand Up @@ -121,7 +121,11 @@ def get_user_filters(**kwargs):
filter_item["variables"] += [
var for var in default_variables if var not in filter_item["variables"]
]
return filter_data
response = {"filters": filter_data}
# get user default filter
user_data = User.get_user_by_user_name(user_name=user.user_name)
response["defaultFilter"] = user_data.default_filter if user_data else None
return response

@staticmethod
@user_context
Expand Down
22 changes: 22 additions & 0 deletions forms-flow-api/src/formsflow_api/services/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

from typing import Dict, List

from formsflow_api_utils.utils.user_context import UserContext, user_context

from formsflow_api.models import User
from formsflow_api.schemas import UserSchema


class UserService:
"""This class manages keycloak user service."""
Expand Down Expand Up @@ -84,3 +89,20 @@ def paginate(self, data, page_number, page_size):
start_index = (page_number - 1) * page_size
end_index = start_index + page_size
return data[start_index:end_index]

@staticmethod
@user_context
def update_user_data(data, **kwargs):
"""Update user data."""
user: UserContext = kwargs["user"]
user_data = User.get_user_by_user_name(user_name=user.user_name)
if user_data:
if user_data.tenant is None and user.tenant_key:
data["tenant"] = user.tenant_key
user_data.update(data)
else:
data["user_name"] = user.user_name
data["tenant"] = user.tenant_key
data["created_by"] = user.user_name
user_data = User.create_user(data)
return UserSchema().dump(user_data)
6 changes: 3 additions & 3 deletions forms-flow-api/tests/unit/api/test_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ def test_get_user_filters(app, client, session, jwt):
# Since reviewer created both filters response will include both.
response = client.get("/filter/user", headers=headers)
assert response.status_code == 200
assert len(response.json) == 2
assert response.json[0].get("name") == "Clerk Task"
assert response.json[1].get("name") == "Reviewer Task"
assert len(response.json.get("filters")) == 2
assert response.json.get("filters")[0].get("name") == "Clerk Task"
assert response.json.get("filters")[1].get("name") == "Reviewer Task"


def test_filter_update(app, client, session, jwt):
Expand Down
28 changes: 27 additions & 1 deletion forms-flow-api/tests/unit/api/test_user.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
"""Test suite for keycloak user API endpoint."""

# from tests import skip_in_ci
from tests.utilities.base_test import get_locale_update_valid_payload, get_token
import json

from tests.utilities.base_test import (
get_filter_payload,
get_locale_update_valid_payload,
get_token,
)


class TestKeycloakUserServiceResource:
Expand Down Expand Up @@ -68,3 +74,23 @@ def test_keycloak_users_list_invalid_group(app, client, session, jwt):
headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"}
rv = client.get("/user?memberOfGroup=test123", headers=headers)
assert rv.status_code == 400


def test_default_filter(app, client, session, jwt):
"""Test create a filter and update default filter of a user."""
token = get_token(jwt, role="formsflow-reviewer", username="reviewer")
headers = {"Authorization": f"Bearer {token}", "content-type": "application/json"}
# Create filter for clerk role
response = client.post(
"/filter",
headers=headers,
json=get_filter_payload(name="Clerk Task", roles=["clerk"]),
)
assert response.status_code == 201
response = client.post(
"/user/default-filter",
headers=headers,
data=json.dumps({"defaultFilter": response.json.get("id")}),
content_type="application/json",
)
assert response.status_code == 200
Loading