diff --git a/ceo/cli/groups.py b/ceo/cli/groups.py index cf2ff5a..5866f40 100644 --- a/ceo/cli/groups.py +++ b/ceo/cli/groups.py @@ -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) diff --git a/ceo/tui/controllers/SearchGroupController.py b/ceo/tui/controllers/SearchGroupController.py new file mode 100644 index 0000000..171bc88 --- /dev/null +++ b/ceo/tui/controllers/SearchGroupController.py @@ -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) diff --git a/ceo/tui/controllers/__init__.py b/ceo/tui/controllers/__init__.py index 7b28a64..10f5195 100644 --- a/ceo/tui/controllers/__init__.py +++ b/ceo/tui/controllers/__init__.py @@ -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 diff --git a/ceo/tui/models/SearchGroupModel.py b/ceo/tui/models/SearchGroupModel.py new file mode 100644 index 0000000..561b915 --- /dev/null +++ b/ceo/tui/models/SearchGroupModel.py @@ -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 diff --git a/ceo/tui/models/WelcomeModel.py b/ceo/tui/models/WelcomeModel.py index ac76086..124a692 100644 --- a/ceo/tui/models/WelcomeModel.py +++ b/ceo/tui/models/WelcomeModel.py @@ -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, ], diff --git a/ceo/tui/models/__init__.py b/ceo/tui/models/__init__.py index 64013d0..3c4f211 100644 --- a/ceo/tui/models/__init__.py +++ b/ceo/tui/models/__init__.py @@ -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 diff --git a/ceo/tui/utils.py b/ceo/tui/utils.py index fa225d8..c2e71c7 100644 --- a/ceo/tui/utils.py +++ b/ceo/tui/utils.py @@ -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) diff --git a/ceo/tui/views/SearchGroupResponseView.py b/ceo/tui/views/SearchGroupResponseView.py new file mode 100644 index 0000000..627e905 --- /dev/null +++ b/ceo/tui/views/SearchGroupResponseView.py @@ -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 diff --git a/ceo/tui/views/SearchGroupView.py b/ceo/tui/views/SearchGroupView.py new file mode 100644 index 0000000..c20bb99 --- /dev/null +++ b/ceo/tui/views/SearchGroupView.py @@ -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) diff --git a/ceo/tui/views/__init__.py b/ceo/tui/views/__init__.py index bb4476e..bf61e2c 100644 --- a/ceo/tui/views/__init__.py +++ b/ceo/tui/views/__init__.py @@ -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 diff --git a/ceo_common/utils.py b/ceo_common/utils.py index bd3f3be..0e4dfc1 100644 --- a/ceo_common/utils.py +++ b/ceo_common/utils.py @@ -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. diff --git a/ceod/api/groups.py b/ceod/api/groups.py index fbeed39..802d7a7 100644 --- a/ceod/api/groups.py +++ b/ceod/api/groups.py @@ -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//') +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('//members/', methods=['POST']) @authz_restrict_to_syscom def add_member_to_group(group_name, username): diff --git a/ceod/model/LDAPService.py b/ceod/model/LDAPService.py index e87a754..fe32025 100644 --- a/ceod/model/LDAPService.py +++ b/ceod/model/LDAPService.py @@ -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]: diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c98a8e4..b030cd7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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'] diff --git a/tests/ceo/cli/test_groups.py b/tests/ceo/cli/test_groups.py index 50c4931..3cbfd89 100644 --- a/tests/ceo/cli/test_groups.py +++ b/tests/ceo/cli/test_groups.py @@ -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() diff --git a/tests/ceod/api/test_groups.py b/tests/ceod/api/test_groups.py index 67388ea..17b408b 100644 --- a/tests/ceod/api/test_groups.py +++ b/tests/ceod/api/test_groups.py @@ -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.