diff --git a/irods/models.py b/irods/models.py index d779bf86b..e1ebf3dfe 100644 --- a/irods/models.py +++ b/irods/models.py @@ -242,10 +242,8 @@ class Ticket(Model): write_file_limit = Column(Integer, "TICKET_WRITE_FILE_LIMIT", 2212) write_byte_count = Column(Integer, "TICKET_WRITE_BYTE_COUNT", 2213) write_byte_limit = Column(Integer, "TICKET_WRITE_BYTE_LIMIT", 2214) - - ## For now, use of these columns raises CAT_SQL_ERR in both PRC and iquest: (irods/irods#5929) - # create_time = Column(String, 'TICKET_CREATE_TIME', 2209) - # modify_time = Column(String, 'TICKET_MODIFY_TIME', 2210) + create_time = Column(DateTime, 'TICKET_CREATE_TIME', 2209, min_version=(4, 3, 0)) + modify_time = Column(DateTime, 'TICKET_MODIFY_TIME', 2210, min_version=(4, 3, 0)) class DataObject(Model): """For queries of R_DATA_MAIN when joining to R_TICKET_MAIN. diff --git a/irods/test/ticket_test.py b/irods/test/ticket_test.py index 56e7c3a10..ef7d62df1 100644 --- a/irods/test/ticket_test.py +++ b/irods/test/ticket_test.py @@ -1,20 +1,25 @@ #! /usr/bin/env python +import calendar +import datetime +import logging import os import sys -import unittest import time -import calendar +import unittest import irods.test.helpers as helpers import tempfile from irods.session import iRODSSession import irods.exception as ex import irods.keywords as kw -from irods.ticket import Ticket +from irods.ticket import enumerate_tickets, Ticket from irods.models import TicketQuery, DataObject, Collection +logger = logging.getLogger(__name__) + + # As with most of the modules in this test suite, session objects created via # make_session() are implicitly agents of a rodsadmin unless otherwise indicated. # Counterexamples within this module shall be obvious as they are instantiated by @@ -29,6 +34,17 @@ def gmtime_to_timestamp(gmt_struct): ) +def delete_tickets(session, dry_run = False): + for res in session.query(TicketQuery.Ticket): + t = Ticket(session, result=res) + if dry_run in (False, None): + t.delete(**{kw.ADMIN_KW: ""}) + elif isinstance(dry_run, list): + dry_run.append(t) + else: + logger.info('Found ticket: %s',t.string) + + def delete_my_tickets(session): my_userid = session.users.get(session.username).id my_tickets = session.query(TicketQuery.Ticket).filter( @@ -358,6 +374,31 @@ def test_coll_read_ticket_between_rodsusers(self): os.unlink(file_.name) alice.cleanup() + def test_new_attributes_in_tickets__issue_801(self): + # Specifically we are testing that 'modify_time' and 'create_time' attributes function as expected, + # and that other attributes such as 'id' are also present. + admin_ticket_for_bob = None + + if (admin:=helpers.make_session()).server_version < (4, 3, 0): + self.skipTest('"create_time" and "modify_time" not supported for Ticket') + + try: + with self.login(self.bob) as bob: + bobs_ticket = Ticket(bob) + bobs_ticket.issue('write', helpers.home_collection(bob)) + time.sleep(2) + bobs_ticket.modify('add', 'user', admin.username) + bobs_ticket = Ticket(bob, result=enumerate_tickets(bob, raw=True), ticket=bobs_ticket.string) + self.assertGreaterEqual( + bobs_ticket.modify_time, + bobs_ticket.create_time + datetime.timedelta(seconds=1) + ) + + admin_ticket_for_bob = Ticket(admin, result=enumerate_tickets(admin, raw=True), ticket=bobs_ticket.string) + self.assertEqual(admin_ticket_for_bob.id, bobs_ticket.id) + finally: + if admin_ticket_for_bob: + admin_ticket_for_bob.delete(**{kw.ADMIN_KW:''}) class TestTicketOps(unittest.TestCase): @@ -456,6 +497,24 @@ def test_coll_ticket_write(self): self._ticket_write_helper(obj_type="coll") + def test_enumerate_tickets__issue_120(self): + + ses = self.sess + + # t first assigned as a "utility" Ticket object + t = Ticket(ses).issue('read', helpers.home_collection(ses)) + + # This time, t receives attributes from an internal GenQuery result. + t = Ticket( + ses, + result=enumerate_tickets(ses, raw=True), + ticket=t.string + ) + + # Check an id attribute is present and listed in the results from list_tickets + self.assertIn(t.id, (_.id for _ in enumerate_tickets(ses))) + + if __name__ == "__main__": # let the tests find the parent irods lib sys.path.insert(0, os.path.abspath("../..")) diff --git a/irods/ticket.py b/irods/ticket.py index 5a213dbcb..034b22949 100644 --- a/irods/ticket.py +++ b/irods/ticket.py @@ -1,6 +1,10 @@ from irods.api_number import api_number from irods.message import iRODSMessage, TicketAdminRequest from irods.models import TicketQuery +from irods.column import Like, Column + +from collections.abc import Mapping, Sequence +from typing import Any, Iterable, Optional, Type, Union import random import string @@ -27,20 +31,86 @@ def get_epoch_seconds(utc_timestamp): except ValueError: raise # final try at conversion, so a failure is an error +class default_ticket_query_factory: + _callable = staticmethod(lambda session: session.query(TicketQuery.Ticket)) + def __call__(self, session): + return self._callable(session) + +def enumerate_tickets(session, *, query_factory=default_ticket_query_factory, raw=False): + """ + Enumerates (via GenQuery1) all tickets visible by, or owned by, the current user. + + Args: + session: An iRODSSession object for use in the query. + query_factory: A class capable of generating a generic query or other iterable + over TicketQuery.Ticket row results. + raw: If false, transform each row returned into a Ticket object; else return + the result rows unaltered. + + Returns: + An iterator over a range of ticket objects. + """ + query = query_factory()(session) + + if raw: + yield from query + else: + yield from (Ticket(session, result=row) for row in query) + +_column_lookup = dict[Type[Column], Any] class Ticket: - def __init__(self, session, ticket="", result=None, allow_punctuation=False): + + def __init__(self, + session, + ticket="", + result: Optional[Union[_column_lookup, Iterable[_column_lookup]]] =None, # Optional (vs. '|') is Python 3.9 syntax + allow_punctuation=False): + self._session = session + try: - if result is not None: + if isinstance(result, Mapping): + if (single_string:=result.get(TicketQuery.Ticket.string, '')): + if ticket and (ticket != single_string): + raise RuntimeError( + f"The specified result contained a ticket string mismatched to the provided identifier ({ticket = })" + ) + + elif hasattr(result, '__iter__'): + if ticket: + result = [row for row in result if row[TicketQuery.Ticket.string] == ticket][:1] + + if not result: + result = None + else: + result = next(iter(result)) # result[0] + + if result: ticket = result[TicketQuery.Ticket.string] + for attr, value in TicketQuery.Ticket.__dict__.items(): + if value is TicketQuery.Ticket.string: continue + if not attr.startswith("_"): + try: + setattr(self, attr, result[value]) + except KeyError: + # backward compatibility with older schema versions + pass + + self._ticket = ticket + except TypeError: raise RuntimeError( - "If specified, 'result' parameter must be a TicketQuery.Ticket search result" + "If specified, 'result' parameter must be a TicketQuery.Ticket search result or iterable of same" ) - self._ticket = ( - ticket if ticket else self._generate(allow_punctuation=allow_punctuation) - ) + + except IndexError: + raise RuntimeError( + "If both result and string are specified, at least one 'result' must match the ticket string" + ) + + if not self._ticket: + self._ticket = self._generate(allow_punctuation=allow_punctuation) @property def session(self):