Skip to content

Commit

Permalink
Merge pull request #2101 from auslin-aot/sync/7-6-2024-develop-to-per…
Browse files Browse the repository at this point in the history
…mission-matrix

🔗[Sync]  Develop to permission matrix
  • Loading branch information
arun-s-aot authored Jun 7, 2024
2 parents 0818b48 + 12e2d8c commit c11b0da
Show file tree
Hide file tree
Showing 24 changed files with 344 additions and 31 deletions.
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

0 comments on commit c11b0da

Please sign in to comment.