FAQ | This is a LIVE service | Changelog

Commit 22515d81 authored by Monty Dawson's avatar Monty Dawson
Browse files

Merge branch 'revoke-duplicate-cards' into 'master'

Revoke duplicate cards

See merge request uis/devops/iam/card-database/sync-tool!22
parents 6cfe7e03 1fa3878b
......@@ -3,3 +3,4 @@ omit =
.tox/*
setup.py
.venv/*
cardsync/__init__.py
\ No newline at end of file
......@@ -8,7 +8,8 @@ Usage:
(
enhanced-cards|card-photos|temporary-cards|available-barcodes|card-notes|
card-logos|holders|cards|barcodes|orphaned-cardholders|diff-enhanced-cards|
diff-temporary-cards|backsync-cards|reconcile-crsids|update-crsids
diff-temporary-cards|backsync-cards|reconcile-crsids|update-crsids|
cardholders-with-multiple-issued-cards|revoke-duplicate-issued-cards
)
Options:
......@@ -48,6 +49,7 @@ from . import image_source
from . import upload
from . import reconcile_crsids
from . import update_crsids
from . import revoke_duplicate_cards
LOG = logging.getLogger(os.path.basename(sys.argv[0]))
......@@ -137,10 +139,17 @@ def main():
"barcodes": source.BARCODES_SELECT,
"available-barcodes": source.AVAILABLE_BARCODES_SELECT,
"card-notes": source.CARD_NOTES_SELECT,
"cardholders-with-multiple-issued-cards": (
revoke_duplicate_cards.SELECT_CARDHOLDERS_WITH_MULTIPLE_ISSUED_CARDS
)
}
connection = source.get_connection(settings['source'])
if opts['revoke-duplicate-issued-cards']:
data = revoke_duplicate_cards.revoke_duplicate_issued_cards(connection)
print(upload.write(settings, 'revoked_duplicate_cards', data, write_dir, export_format))
if opts['reconcile-crsids']:
if not settings.get('jackdaw') or not settings.get('jackdaw').get('dsn'):
raise ValueError('No `jackdaw` configuration provided in configuration file')
......
......@@ -3,6 +3,8 @@ from cx_Oracle import Connection
from logging import getLogger
from Levenshtein import distance
from .source import select
LOG = getLogger(__name__)
......@@ -74,22 +76,6 @@ SELECT_CONNECTED_ORG_NAMES = """
"""
def select(connection: Connection, select_statement: str, **kwargs):
"""
Utility method to run select against the given connection and return a generator of dicts.
"""
cursor = connection.cursor()
cursor.execute(select_statement, **kwargs)
field_names = [d[0].lower() for d in cursor.description]
for row in cursor:
yield {
field_name: row[index]
for index, field_name in enumerate(field_names)
}
def get_connected_org_names(card_connection: Connection, cam_uid: str):
"""
Return a string of all the connected organisation names for the given cardholder
......
from cx_Oracle import Connection
from logging import getLogger
from .source import select
LOG = getLogger(__name__)
"""
Revokes duplicate issued cards which exist within the card database. Duplicates are detected by
finding a cardholders with multiple issued cards and then revoking all but the card with the
latest issue number or card id (where multiple cards are found with the same issue number).
The root cause of the duplicate issued cards is tracked here:
* https://gitlab.developers.cam.ac.uk/uis/devops/iam/card-database/card-system/-/issues/32
This can be run locally using:
```shell
logan --docker-run-args "-v $(pwd)/config.yml:/config.yml -v $(pwd)/output:/output" \
-- -c /config.yml revoke-duplicate-issued-cards --dir /output
```
"""
SELECT_CARDHOLDERS_WITH_MULTIPLE_ISSUED_CARDS = """
SELECT card.cam_uid, card.display_name, count(*) as issued_card_count FROM cards card
WHERE
card.TERM_DATE IS NULL AND
card.RETURNED_DATE IS NULL AND
card.ISSUE_DATE IS NOT NULL AND
(card.EXPIRY_DATE IS NULL OR card.EXPIRY_DATE >= trunc(sysdate))
group by card.cam_uid, card.display_name
having count(*) > 1
"""
SELECT_ISSUED_CARDS_FOR_CARDHOLDER = """
SELECT card.card_id, card.issue_number FROM cards card
WHERE
card.TERM_DATE IS NULL AND
card.RETURNED_DATE IS NULL AND
card.ISSUE_DATE IS NOT NULL AND
(card.EXPIRY_DATE IS NULL OR card.EXPIRY_DATE >= trunc(sysdate)) AND
card.cam_uid = :cam_uid
"""
REVOKE_CARD_AS_DUPLICATE = """
UPDATE cards
set
TERM_DATE = trunc(sysdate),
REASON = 'Duplicate'
WHERE
card_id = :card_id
"""
def revoke_duplicate_issued_cards_for_cam_uid(connection: Connection, cam_uid: str):
"""
Revokes duplicate issued cards for a given cardholder identified by cam_uid, removing
all but the most recently issued card.
"""
issued_cards_for_cardholder = select(
connection, SELECT_ISSUED_CARDS_FOR_CARDHOLDER, cam_uid=cam_uid
)
# We want to revoke all but the card with the highest issue_number or card_id, so
# we create a list of card_ids sorted by issue_number and card_id and then split out
# the last element.
duplicate_card_ids = [
card['card_id']
for card in sorted(issued_cards_for_cardholder, key=lambda card: (
card['issue_number'], card['card_id']
))
]
if len(duplicate_card_ids) < 2:
raise ValueError(f'Could not find duplicate cards for cardholder {cam_uid}')
card_ids_to_revoke = duplicate_card_ids[:-1]
for card_id in card_ids_to_revoke:
cursor = connection.cursor()
cursor.execute(REVOKE_CARD_AS_DUPLICATE, card_id=card_id)
if cursor.rowcount != 1:
raise ValueError(
f'Could not revoke card {card_id} for cardholder {cam_uid}, '
f'number of affected rows is expected to be 1 but is {cursor.rowcount}'
)
cursor.execute('COMMIT')
return card_ids_to_revoke
def revoke_duplicate_issued_cards(connection: Connection):
"""
Fetches cardholders with duplicated cards and revokes all but the most recently
issued card held by a given cardholder.
"""
cam_uids_with_duplicate_cards = [
card['cam_uid']
for card in select(connection, SELECT_CARDHOLDERS_WITH_MULTIPLE_ISSUED_CARDS)
]
data = [['cam_uid', 'card_id']]
for cam_uid in cam_uids_with_duplicate_cards:
LOG.info(f'Revoking duplicate issued cards for {cam_uid}')
revoked_card_ids = revoke_duplicate_issued_cards_for_cam_uid(connection, cam_uid)
for card_id in revoked_card_ids:
data.append([cam_uid, card_id])
return data
......@@ -149,6 +149,22 @@ def get_orphan_cards(connection: Connection,
return results
def select(connection: Connection, select_statement: str, **kwargs):
"""
Utility method to run select against the given connection and return a generator of dicts.
"""
cursor = connection.cursor()
cursor.execute(select_statement, **kwargs)
field_names = [d[0].lower() for d in cursor.description]
for row in cursor:
yield {
field_name: row[index]
for index, field_name in enumerate(field_names)
}
def make_select(select, order_by=None):
if order_by:
head, sep, _ = select.partition('ORDER BY')
......
from unittest import TestCase
from cardsync.tests.db_mock import MockConnection
from cardsync import revoke_duplicate_cards
class RevokeDuplicateCardsTestCase(TestCase):
def test_returns_nothing_with_no_records_to_revoke(self):
mock_connection = MockConnection()
result = revoke_duplicate_cards.revoke_duplicate_issued_cards(mock_connection)
self.assertEqual(result, [['cam_uid', 'card_id']])
self.assertEqual(
mock_connection.get_queries_executed(),
[(revoke_duplicate_cards.SELECT_CARDHOLDERS_WITH_MULTIPLE_ISSUED_CARDS, {})]
)
def test_revokes_duplicates_for_cardholder(self):
mock_connection = MockConnection([
# result for the initial query to get cardholders with duplicates
[
['cam_uid', 'count'],
['ab123c', 3],
],
# result for the second query to get the duplicates for a given cardholder
[
['issue_number', 'card_id'],
[3, 101],
[2, 100],
[2, 99],
],
# results from the query to remove the duplicates
[[True], [True]],
[],
[[True], [True]],
[]
])
result = revoke_duplicate_cards.revoke_duplicate_issued_cards(mock_connection)
# the output should show that the 100 and 99 cards have been revoked
self.assertEqual(result, [
['cam_uid', 'card_id'],
['ab123c', 99],
['ab123c', 100],
])
self.assertEqual(
mock_connection.get_queries_executed(),
[
(revoke_duplicate_cards.SELECT_CARDHOLDERS_WITH_MULTIPLE_ISSUED_CARDS, {}),
(revoke_duplicate_cards.SELECT_ISSUED_CARDS_FOR_CARDHOLDER, {'cam_uid': 'ab123c'}),
(revoke_duplicate_cards.REVOKE_CARD_AS_DUPLICATE, {'card_id': 99}),
('COMMIT', {}),
(revoke_duplicate_cards.REVOKE_CARD_AS_DUPLICATE, {'card_id': 100}),
('COMMIT', {}),
]
)
def test_can_revoke_multiple_duplicates(self):
mock_connection = MockConnection([
# result for the initial query to get cardholders with duplicates
[
['cam_uid', 'count'],
['x12c', 2],
['b12c', 2],
],
# result for the query to get the duplicates for the first cardholder
[
['issue_number', 'card_id'],
[4, 300],
[3, 299],
],
# results from the query to remove the first duplicate
[[True], [True]],
[],
# result for the query to get the duplicates for the second cardholder
[
['issue_number', 'card_id'],
[4, 400],
[3, 399],
],
# results from the query to remove the second duplicate
[[True], [True]],
[],
])
result = revoke_duplicate_cards.revoke_duplicate_issued_cards(mock_connection)
# the output should show that the 299 and 399 cards have been revoked
self.assertEqual(result, [
['cam_uid', 'card_id'],
['x12c', 299],
['b12c', 399],
])
self.assertEqual(
mock_connection.get_queries_executed(),
[
(revoke_duplicate_cards.SELECT_CARDHOLDERS_WITH_MULTIPLE_ISSUED_CARDS, {}),
(revoke_duplicate_cards.SELECT_ISSUED_CARDS_FOR_CARDHOLDER, {'cam_uid': 'x12c'}),
(revoke_duplicate_cards.REVOKE_CARD_AS_DUPLICATE, {'card_id': 299}),
('COMMIT', {}),
(revoke_duplicate_cards.SELECT_ISSUED_CARDS_FOR_CARDHOLDER, {'cam_uid': 'b12c'}),
(revoke_duplicate_cards.REVOKE_CARD_AS_DUPLICATE, {'card_id': 399}),
('COMMIT', {}),
]
)
def test_fails_when_revoking_one_card_touches_many(self):
mock_connection = MockConnection([
# result for the initial query to get cardholders with duplicates
[
['cam_uid', 'count'],
['z123x', 2],
],
# result for the query to get the duplicates for the first cardholder
[
['issue_number', 'card_id'],
[6, 500],
[6, 499],
],
# results from the query to remove the first duplicate - three list here indicate
# a row count of two - with the initial row being taken as the header row
# (indicating the names of the columns)
[[True], [True], [True]],
])
with self.assertRaisesRegex(
ValueError,
(
'Could not revoke card 499 for cardholder z123x, number of affected rows '
'is expected to be 1 but is 2'
)
):
revoke_duplicate_cards.revoke_duplicate_issued_cards(mock_connection)
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment