Add group lookup functionality #88

Merged
r389li merged 12 commits from 60-group-lookup into master 2023-03-04 01:21:06 -05:00
16 changed files with 439 additions and 1 deletions

View File

@ -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)

View 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)

View File

@ -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

View 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

View File

@ -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,
],

View File

@ -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

View File

@ -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)

View 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

View 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)

View File

@ -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

View File

@ -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.

View File

@ -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)
Review

The code related to fuzzy searching should be in a separate file, just so we can keep the API routes tidy.

The code related to fuzzy searching should be in a separate file, just so we can keep the API routes tidy.
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):

View File

@ -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]:

View File

@ -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']

View File

@ -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()

View File

@ -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.