Add group lookup functionality (#88)
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
note: **I am unaware of best practices** but I tried my best to keep changes consistent with the codebase feedback would be much appreciated notable changes: **new api endpoint**: `/groups/search` -- I moved searching into the api so it could be used in tui and cli, also seemed like a good idea to keep the json response as small as possible **tui searching** -- at first I wanted to make this realtime interactable, but the work required seemed inappropriate to a feature I am assuming will only be used sparingly Co-authored-by: Daniel Sun <dandancool@github.com> Co-authored-by: Daniel Sun <d6sun@uwaterloo.ca> Reviewed-on: #88 Reviewed-by: Max Erenberg <merenber@csclub.uwaterloo.ca> Co-authored-by: Daniel Sun <d6sun@csclub.uwaterloo.ca> Co-committed-by: Daniel Sun <d6sun@csclub.uwaterloo.ca>
This commit is contained in:
parent
234ab62f27
commit
010937ea17
@ -149,3 +149,15 @@ def delete(group_name):
|
||||
click.confirm(f"Are you sure you want to delete {group_name}?", abort=True)
|
||||
resp = http_delete(f'/api/groups/{group_name}')
|
||||
handle_stream_response(resp, DeleteGroupTransaction.operations)
|
||||
|
||||
|
||||
@groups.command(short_help='Search for groups')
|
||||
@click.argument('query')
|
||||
@click.option('--count', default=10, help='number of results to show')
|
||||
def search(query, count):
|
||||
check_if_in_development()
|
||||
resp = http_get(f'/api/groups/search/{query}/{count}')
|
||||
result = handle_sync_response(resp)
|
||||
for cn in result:
|
||||
if cn != "":
|
||||
click.echo(cn)
|
||||
|
40
ceo/tui/controllers/SearchGroupController.py
Normal file
40
ceo/tui/controllers/SearchGroupController.py
Normal file
@ -0,0 +1,40 @@
|
||||
from ceo.utils import http_get
|
||||
from .Controller import Controller
|
||||
from .SyncRequestController import SyncRequestController
|
||||
from ceo.tui.views import SearchGroupResponseView, GetGroupResponseView
|
||||
|
||||
|
||||
# this is a little bit bad because it relies on zero coupling between
|
||||
# the GetGroupResponseView and the GetGroupController
|
||||
# coupling is also introduced between this controller and the
|
||||
# SearchGroupResponseView as it requires this class's callback
|
||||
class SearchGroupController(SyncRequestController):
|
||||
def __init__(self, model, app):
|
||||
super().__init__(model, app)
|
||||
|
||||
def get_resp(self):
|
||||
if self.model.want_info:
|
||||
return http_get(f'/api/groups/{self.model.name}')
|
||||
else:
|
||||
return http_get(f'/api/groups/search/{self.model.name}/{self.model.count}')
|
||||
|
||||
def get_response_view(self):
|
||||
if self.model.want_info:
|
||||
return GetGroupResponseView(self.model, self, self.app)
|
||||
else:
|
||||
return SearchGroupResponseView(self.model, self, self.app)
|
||||
|
||||
def group_info_callback(self, button, cn):
|
||||
self.model.name = cn
|
||||
self.model.want_info = True
|
||||
self.request_in_progress = False
|
||||
self.on_next_button_pressed(button)
|
||||
|
||||
def on_next_button_pressed(self, button):
|
||||
try:
|
||||
if not self.model.want_info:
|
||||
self.model.name = self.get_username_from_view()
|
||||
self.model.count = 10
|
||||
except Controller.InvalidInput:
|
||||
return
|
||||
self.on_confirmation_button_pressed(button)
|
@ -8,6 +8,7 @@ from .ResetPasswordController import ResetPasswordController
|
||||
from .ChangeLoginShellController import ChangeLoginShellController
|
||||
from .AddGroupController import AddGroupController
|
||||
from .GetGroupController import GetGroupController
|
||||
from .SearchGroupController import SearchGroupController
|
||||
from .AddMemberToGroupController import AddMemberToGroupController
|
||||
from .RemoveMemberFromGroupController import RemoveMemberFromGroupController
|
||||
from .CreateDatabaseController import CreateDatabaseController
|
||||
|
9
ceo/tui/models/SearchGroupModel.py
Normal file
9
ceo/tui/models/SearchGroupModel.py
Normal file
@ -0,0 +1,9 @@
|
||||
class SearchGroupModel:
|
||||
name = 'SearchGroup'
|
||||
title = 'Search groups'
|
||||
|
||||
def __init__(self):
|
||||
self.name = ''
|
||||
self.resp_json = None
|
||||
self.count = 10
|
||||
self.want_info = False
|
@ -5,6 +5,7 @@ from .ResetPasswordModel import ResetPasswordModel
|
||||
from .ChangeLoginShellModel import ChangeLoginShellModel
|
||||
from .AddGroupModel import AddGroupModel
|
||||
from .GetGroupModel import GetGroupModel
|
||||
from .SearchGroupModel import SearchGroupModel
|
||||
from .AddMemberToGroupModel import AddMemberToGroupModel
|
||||
from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel
|
||||
from .CreateDatabaseModel import CreateDatabaseModel
|
||||
@ -29,6 +30,7 @@ class WelcomeModel:
|
||||
'Groups': [
|
||||
AddGroupModel,
|
||||
GetGroupModel,
|
||||
SearchGroupModel,
|
||||
AddMemberToGroupModel,
|
||||
RemoveMemberFromGroupModel,
|
||||
],
|
||||
|
@ -6,6 +6,7 @@ from .ResetPasswordModel import ResetPasswordModel
|
||||
from .ChangeLoginShellModel import ChangeLoginShellModel
|
||||
from .AddGroupModel import AddGroupModel
|
||||
from .GetGroupModel import GetGroupModel
|
||||
from .SearchGroupModel import SearchGroupModel
|
||||
from .AddMemberToGroupModel import AddMemberToGroupModel
|
||||
from .RemoveMemberFromGroupModel import RemoveMemberFromGroupModel
|
||||
from .CreateDatabaseModel import CreateDatabaseModel
|
||||
|
@ -25,6 +25,7 @@ def handle_sync_response(resp, controller):
|
||||
raise Controller.RequestFailed()
|
||||
|
||||
|
||||
# this can probably be simplified with getattr or something
|
||||
def get_mvc(app, name):
|
||||
if name == WelcomeModel.name:
|
||||
model = WelcomeModel()
|
||||
@ -58,6 +59,10 @@ def get_mvc(app, name):
|
||||
model = GetGroupModel()
|
||||
controller = GetGroupController(model, app)
|
||||
view = GetGroupView(model, controller, app)
|
||||
elif name == SearchGroupModel.name:
|
||||
model = SearchGroupModel()
|
||||
controller = SearchGroupController(model, app)
|
||||
view = SearchGroupView(model, controller, app)
|
||||
elif name == AddMemberToGroupModel.name:
|
||||
model = AddMemberToGroupModel()
|
||||
controller = AddMemberToGroupController(model, app)
|
||||
|
21
ceo/tui/views/SearchGroupResponseView.py
Normal file
21
ceo/tui/views/SearchGroupResponseView.py
Normal file
@ -0,0 +1,21 @@
|
||||
import urwid
|
||||
|
||||
from .ColumnResponseView import ColumnResponseView
|
||||
from .utils import decorate_button
|
||||
|
||||
|
||||
class SearchGroupResponseView(ColumnResponseView):
|
||||
def __init__(self, model, controller, app):
|
||||
super().__init__(model, controller, app)
|
||||
matches = self.model.resp_json.copy()
|
||||
|
||||
rows = [(urwid.Text(resp),
|
||||
decorate_button(urwid.Button('more info', on_press=self.create_callback(resp))))
|
||||
for resp in matches if resp != '']
|
||||
|
||||
self.set_rows(rows, on_next=self.controller.get_next_menu_callback('Welcome'))
|
||||
|
||||
def create_callback(self, cn):
|
||||
def callback(button):
|
||||
self.controller.group_info_callback(button, cn)
|
||||
return callback
|
17
ceo/tui/views/SearchGroupView.py
Normal file
17
ceo/tui/views/SearchGroupView.py
Normal file
@ -0,0 +1,17 @@
|
||||
import urwid
|
||||
|
||||
from .ColumnView import ColumnView
|
||||
|
||||
|
||||
class SearchGroupView(ColumnView):
|
||||
def __init__(self, model, controller, app):
|
||||
super().__init__(model, controller, app)
|
||||
# used for query, consider combining Controller.user_name_from_view and Controller.get_group_name_from_view
|
||||
self.username_edit = urwid.Edit()
|
||||
rows = [
|
||||
(
|
||||
urwid.Text('Query:', align='right'),
|
||||
self.username_edit
|
||||
)
|
||||
]
|
||||
self.set_rows(rows)
|
@ -16,6 +16,8 @@ from .AddGroupView import AddGroupView
|
||||
from .AddGroupConfirmationView import AddGroupConfirmationView
|
||||
from .GetGroupView import GetGroupView
|
||||
from .GetGroupResponseView import GetGroupResponseView
|
||||
from .SearchGroupView import SearchGroupView
|
||||
from .SearchGroupResponseView import SearchGroupResponseView
|
||||
from .AddMemberToGroupView import AddMemberToGroupView
|
||||
from .AddMemberToGroupConfirmationView import AddMemberToGroupConfirmationView
|
||||
from .RemoveMemberFromGroupView import RemoveMemberFromGroupView
|
||||
|
@ -1,6 +1,52 @@
|
||||
import datetime
|
||||
|
||||
|
||||
class fuzzy_result:
|
||||
def __init__(self, string, score):
|
||||
self.string = string
|
||||
self.score = score
|
||||
|
||||
# consider a score worse if the edit distance is larger
|
||||
def __lt__(self, other):
|
||||
return self.score > other.score
|
||||
|
||||
def __gt__(self, other):
|
||||
return self.score < other.score
|
||||
|
||||
def __le__(self, other):
|
||||
return self.score >= other.score
|
||||
|
||||
def __ge__(self, other):
|
||||
return self.score <= other.score
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.score == other.score
|
||||
|
||||
def __ne__(self, other):
|
||||
return self.score != other.score
|
||||
|
||||
|
||||
# compute levenshtein edit distance, adapted from rosetta code
|
||||
def fuzzy_match(s1, s2):
|
||||
if len(s1) == 0:
|
||||
return len(s2)
|
||||
if len(s2) == 0:
|
||||
return len(s1)
|
||||
edits = [i for i in range(len(s2) + 1)]
|
||||
for i in range(len(s1)):
|
||||
corner = i
|
||||
edits[0] = i + 1
|
||||
for j in range(len(s2)):
|
||||
upper = edits[j + 1]
|
||||
if s1[i] == s2[j]:
|
||||
edits[j + 1] = corner
|
||||
else:
|
||||
m = min(corner, upper, edits[j])
|
||||
edits[j + 1] = m + 1
|
||||
corner = upper
|
||||
return edits[-1]
|
||||
|
||||
|
||||
def get_current_datetime() -> datetime.datetime:
|
||||
# We place this in a separate function so that we can mock it out
|
||||
# in our unit tests.
|
||||
|
@ -1,9 +1,11 @@
|
||||
from flask import Blueprint, request
|
||||
from flask.json import jsonify
|
||||
from zope import component
|
||||
|
||||
from .utils import authz_restrict_to_syscom, is_truthy, \
|
||||
create_streaming_response, development_only
|
||||
from ceo_common.interfaces import ILDAPService
|
||||
from ceo_common.utils import fuzzy_result, fuzzy_match
|
||||
from ceod.transactions.groups import (
|
||||
AddGroupTransaction,
|
||||
AddMemberToGroupTransaction,
|
||||
@ -11,6 +13,8 @@ from ceod.transactions.groups import (
|
||||
DeleteGroupTransaction,
|
||||
)
|
||||
|
||||
from heapq import heappushpop, nlargest
|
||||
|
||||
bp = Blueprint('groups', __name__)
|
||||
|
||||
|
||||
@ -32,6 +36,22 @@ def get_group(group_name):
|
||||
return group.to_dict()
|
||||
|
||||
|
||||
@bp.route('/search/<query>/<count>')
|
||||
def search_group(query, count):
|
||||
query = str(query)
|
||||
count = int(count)
|
||||
ldap_srv = component.getUtility(ILDAPService)
|
||||
clubs = ldap_srv.get_clubs()
|
||||
scores = [fuzzy_result("", 99999) for _ in range(count)]
|
||||
for club in clubs:
|
||||
score = fuzzy_match(query, str(club.cn))
|
||||
result = fuzzy_result(str(club.cn), score)
|
||||
heappushpop(scores, result)
|
||||
|
||||
result = [score.string for score in nlargest(count, scores)]
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.route('/<group_name>/members/<username>', methods=['POST'])
|
||||
@authz_restrict_to_syscom
|
||||
def add_member_to_group(group_name, username):
|
||||
|
@ -360,7 +360,11 @@ class LDAPService:
|
||||
return users_to_change
|
||||
|
||||
def _get_club_uids(self, conn: ldap3.Connection) -> List[str]:
|
||||
conn.search(self.ldap_users_base, '(objectClass=club)', attributes=['uid'])
|
||||
conn.extend.standard.paged_search(self.ldap_users_base,
|
||||
'(objectClass=club)',
|
||||
attributes=['uid'],
|
||||
paged_size=50,
|
||||
generator=False)
|
||||
return [entry.uid.value for entry in conn.entries]
|
||||
|
||||
def get_clubs(self) -> List[IGroup]:
|
||||
|
@ -284,6 +284,26 @@ paths:
|
||||
$ref: "#/components/schemas/UID"
|
||||
"404":
|
||||
$ref: "#/components/responses/GroupNotFoundErrorResponse"
|
||||
/groups/{query}/{count}:
|
||||
get:
|
||||
tags: ['groups']
|
||||
summary: fuzzy search groups
|
||||
description: >-
|
||||
search count number of groups, returns a list of names sorted by levenshtein edit
|
||||
distance.
|
||||
parameters:
|
||||
- name: query
|
||||
in: path
|
||||
description: query or string to search for
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: count
|
||||
in: path
|
||||
description: number of results to return, returns empty strings if necessary
|
||||
required: true
|
||||
schema:
|
||||
type: int
|
||||
/groups/{group_name}/members/{username}:
|
||||
post:
|
||||
tags: ['groups']
|
||||
|
@ -77,6 +77,44 @@ def test_groups(cli_setup, ldap_user):
|
||||
result = runner.invoke(cli, ['groups', 'delete', 'test_group_1'], input='y\n')
|
||||
assert result.exit_code == 0
|
||||
|
||||
group_names = [
|
||||
"touch",
|
||||
"error",
|
||||
"happy",
|
||||
"moon",
|
||||
"decisive",
|
||||
"exciting",
|
||||
"super",
|
||||
"ambitious",
|
||||
"acidic",
|
||||
"addition",
|
||||
"blue-eyed",
|
||||
"grate",
|
||||
"replace",
|
||||
"natural",
|
||||
"explode",
|
||||
"decorous",
|
||||
"wide",
|
||||
"hang",
|
||||
"tomatoes",
|
||||
"thirsty",
|
||||
]
|
||||
runner = CliRunner()
|
||||
for name in group_names:
|
||||
result = runner.invoke(cli, [
|
||||
'groups', 'add', name, '-d', 'searchable group',
|
||||
], input='y\n')
|
||||
assert result.exit_code == 0
|
||||
|
||||
for name in group_names:
|
||||
result = runner.invoke(cli, ['groups', 'search', '--count=1', name])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.find(name) != -1
|
||||
|
||||
for name in group_names:
|
||||
result = runner.invoke(cli, ['groups', 'delete', name], input='y\n')
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def create_group(group_name, desc):
|
||||
runner = CliRunner()
|
||||
|
@ -111,6 +111,206 @@ def test_api_add_member_to_group(client, create_group_result, ldap_user):
|
||||
assert data['members'] == []
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def create_random_names():
|
||||
# 150 random names generated with https://www.randomlists.com/random-words
|
||||
random_names = [
|
||||
"intelligent",
|
||||
"skin",
|
||||
"shivering",
|
||||
"hapless",
|
||||
"abstracted",
|
||||
"kiss",
|
||||
"decision",
|
||||
"van",
|
||||
"advise",
|
||||
"parcel",
|
||||
"disillusioned",
|
||||
"print",
|
||||
"skate",
|
||||
"robin",
|
||||
"explode",
|
||||
"fearless",
|
||||
"feeling",
|
||||
"chemical",
|
||||
"identify",
|
||||
"baseball",
|
||||
"room",
|
||||
"contain",
|
||||
"smooth",
|
||||
"play",
|
||||
"fierce",
|
||||
"north",
|
||||
"secretive",
|
||||
"plug",
|
||||
"rely",
|
||||
"home",
|
||||
"push",
|
||||
"guard",
|
||||
"allow",
|
||||
"depressed",
|
||||
"evasive",
|
||||
"slap",
|
||||
"delicate",
|
||||
"concern",
|
||||
"consider",
|
||||
"fang",
|
||||
"roll",
|
||||
"bait",
|
||||
"rabbits",
|
||||
"guarded",
|
||||
"abnormal",
|
||||
"loutish",
|
||||
"voracious",
|
||||
"chase",
|
||||
"army",
|
||||
"harsh",
|
||||
"grieving",
|
||||
"tacky",
|
||||
"far",
|
||||
"wise",
|
||||
"street",
|
||||
"price",
|
||||
"bikes",
|
||||
"post",
|
||||
"afternoon",
|
||||
"deranged",
|
||||
"cart",
|
||||
"evanescent",
|
||||
"shrill",
|
||||
"uppity",
|
||||
"adhoc",
|
||||
"alleged",
|
||||
"round",
|
||||
"smart",
|
||||
"support",
|
||||
"plantation",
|
||||
"flap",
|
||||
"pretty",
|
||||
"radiate",
|
||||
"excite",
|
||||
"memorize",
|
||||
"whisper",
|
||||
"thoughtless",
|
||||
"substantial",
|
||||
"upset",
|
||||
"pathetic",
|
||||
"flow",
|
||||
"shake",
|
||||
"wail",
|
||||
"share",
|
||||
"songs",
|
||||
"scream",
|
||||
"aspiring",
|
||||
"overwrought",
|
||||
"mass",
|
||||
"romantic",
|
||||
"deliver",
|
||||
"anxious",
|
||||
"laborer",
|
||||
"angry",
|
||||
"faded",
|
||||
"wish",
|
||||
"homeless",
|
||||
"salty",
|
||||
"start",
|
||||
"crooked",
|
||||
"tremble",
|
||||
"enjoy",
|
||||
"chivalrous",
|
||||
"useless",
|
||||
"womanly",
|
||||
"brake",
|
||||
"wandering",
|
||||
"please",
|
||||
"cow",
|
||||
"reason",
|
||||
"expert",
|
||||
"null",
|
||||
"basket",
|
||||
"early",
|
||||
"river",
|
||||
"prevent",
|
||||
"sticks",
|
||||
"vacation",
|
||||
"eggnog",
|
||||
"receive",
|
||||
"memory",
|
||||
"exchange",
|
||||
"burly",
|
||||
"agreement",
|
||||
"flock",
|
||||
"subdued",
|
||||
"clap",
|
||||
"simplistic",
|
||||
"tiger",
|
||||
"responsible",
|
||||
"knock",
|
||||
"camera",
|
||||
"nifty",
|
||||
"capable",
|
||||
"disappear",
|
||||
"afterthought",
|
||||
"obese",
|
||||
"harass",
|
||||
"delicious",
|
||||
"badge",
|
||||
"dam",
|
||||
"plate",
|
||||
"acrid",
|
||||
"voiceless",
|
||||
"mate",
|
||||
"juice",
|
||||
"food",
|
||||
"town",
|
||||
"giraffe",
|
||||
"decorate"
|
||||
]
|
||||
yield random_names
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def create_searchable_groups(client, create_random_names):
|
||||
random_names = create_random_names
|
||||
for name in random_names:
|
||||
status, data = client.post('/api/groups', json={
|
||||
'cn': name,
|
||||
'description': 'Groups with distinct names for testing searching',
|
||||
})
|
||||
assert status == 200
|
||||
assert data[-1]['status'] == 'completed'
|
||||
yield random_names
|
||||
|
||||
|
||||
def test_api_group_search(client, create_searchable_groups):
|
||||
cns = create_searchable_groups
|
||||
# pairs of cn indices as well as amount of results that should be returned
|
||||
random_numbers = [
|
||||
(88, 68),
|
||||
(117, 54),
|
||||
(63, 97),
|
||||
(64, 19),
|
||||
(114, 98),
|
||||
(45, 146),
|
||||
(58, 12),
|
||||
(42, 126),
|
||||
(66, 137),
|
||||
(39, 135),
|
||||
]
|
||||
|
||||
for tup in random_numbers:
|
||||
cn = cns[tup[0]]
|
||||
status, data = client.get(f'/api/groups/search/{cn}/{tup[1]}')
|
||||
assert status == 200
|
||||
assert len(data) == tup[1]
|
||||
assert data[0] == cn
|
||||
|
||||
for cn in cns:
|
||||
status, data = client.delete(f'/api/groups/{cn}')
|
||||
assert status == 200
|
||||
assert data[-1]['status'] == 'completed'
|
||||
|
||||
|
||||
def test_api_group_auxiliary(cfg, client, ldap_user, g_admin_ctx):
|
||||
# Make sure that syscom has auxiliary mailing lists and groups
|
||||
# defined in ceod_test_local.ini.
|
||||
|
Loading…
x
Reference in New Issue
Block a user