This is an automated email from the ASF dual-hosted git repository.

gstein pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/steve.git

commit 32c377a7109a5f55348047183d7bac3d0cde6e33
Author: Greg Stein <[email protected]>
AuthorDate: Sat Oct 11 14:04:42 2025 -0500

    Error handling for missing Elections and Issues.
    
    * queries.yaml: add q_check_election to quick examine if an EID exists
    * election.py:
      - new exceptions in steve.election: ElectionNotFound,
        ElectionBadState, IssueNotFound
      - not_found(): new helper to look for an item (only used for an
        Election at the moment)
      - Election.__init__: verify the EID exists
      - new: _all_metadata() to query and return metadata for an Election,
        including the "secret" stuff (SALT, OPENED_KEY). This function
        also handles if/when an Election has been deleted in the database
        behind the Election instance's back. This method can also check
        and enforce the Election is in a particular state, and raises
        ElectionBadState() if not.
      - new: _disappeared for when an Election disappears. Disable SELF so
        it cannot be further used.
      - (various): switch from q_get_metadata and self.is_editable/is_open
        to the new _all_metadata method.
      - get_issue, delete_issue: raise IssueNotFound as appropriate
      - factor out _compute_state() and use from a couple locations
---
 v3/queries.yaml      |   3 ++
 v3/steve/election.py | 114 +++++++++++++++++++++++++++++++++++++++++----------
 2 files changed, 95 insertions(+), 22 deletions(-)

diff --git a/v3/queries.yaml b/v3/queries.yaml
index 450cf2d..65a9330 100644
--- a/v3/queries.yaml
+++ b/v3/queries.yaml
@@ -58,6 +58,9 @@ election:
     c_delete_issues: DELETE FROM issue WHERE eid = ?
     c_delete_election: DELETE FROM election WHERE eid = ?
 
+    # Fast check to see if the Election exists. No data returned.
+    q_check_election: SELECT 1 FROM election WHERE eid = ?
+
     q_metadata: SELECT * FROM election WHERE eid = ?
     q_issues: SELECT * FROM issue WHERE eid = ? ORDER BY iid
     q_get_issue: SELECT * FROM issue WHERE iid = ?
diff --git a/v3/steve/election.py b/v3/steve/election.py
index bd36bd9..cfdf0f1 100644
--- a/v3/steve/election.py
+++ b/v3/steve/election.py
@@ -55,6 +55,9 @@ class Election:
         self.db = self.open_database(db_fname)
         self.eid = eid
 
+        if not_found(self.q_check_election, eid):
+            raise ElectionNotFound(eid)
+
     def __getattr__(self, name):
         "Proxy the cursors."
         return self.__dict__.get(name, getattr(self.db, name))
@@ -65,6 +68,7 @@ class Election:
         # Can't delete if it has been opened (even if later closed).
         assert self.is_editable()
 
+        # Normally, we are in auto-commit mode. Switch to transactional.
         self.db.conn.execute('BEGIN TRANSACTION')
 
         # Order these things because of referential integrity.
@@ -111,7 +115,7 @@ class Election:
 
         # NOTE: all assembly of rows must use a repeatable ordering.
 
-        md = self.q_metadata.first_row(self.eid)
+        md = self._all_metadata()
         mdata = md.eid + md.title
 
         self.q_issues.perform(self.eid)
@@ -143,7 +147,10 @@ class Election:
 
         # Use Q_ALL_ISSUES to iterate over all Person/Issue mappings
         # in this Election (specified by EID).
+
+        # Normally, we are in auto-commit mode. Switch to transactional.
         self.db.conn.execute('BEGIN TRANSACTION')
+
         self.q_all_issues.perform(self.eid)
         for mayvote in self.q_all_issues.fetchall():
             # MAYVOTE is a 1-tuple: _ROWID_
@@ -155,21 +162,48 @@ class Election:
 
         self.db.conn.execute('COMMIT')
 
+    def _disappeared(self):
+        "The Election disappeared in the database. Disable SELF."
+
+        # Disable this instance.
+        self.db.conn.close()
+        self.db = None
+
+        # The caller may want to inform this EID no longer exists.
+        return ElectionNotFound(self.eid)
+
+    def _all_metadata(self, required_state=None):
+        "INTERNAL ONLY: return all metadata about this Election."
+
+        # NOTE: this returns the SALE and OPENED_KEY columns. This
+        # API is not for public use.
+        md = self.q_metadata.first_row(self.eid)
+        if not md:
+            raise self._disappeared()
+
+        if required_state:
+            state = self._compute_state(md)
+            if state != required_state:
+                raise ElectionBadState(state, required_state)
+
+        return md
+
     def get_metadata(self):
         "Return basic metadata about this Election."
 
-        md = self.q_metadata.first_row(self.eid)
-        # NOTE: do not return the SALT
-        # note: likely: never return opened_key
+        md = self._all_metadata()
+        # NOTE: do not return the SALT or OPENED_KEY
 
-        return md.eid, md.title, self.get_state()
+        return md.eid, md.title, self._compute_state(md)
 
     def get_issue(self, iid):
         "Return TITLE, DESCRIPTION, TYPE, and KV for issue IID."
 
-        # NEVER return issue.salt
         issue = self.q_get_issue.first_row(iid)
+        if not issue:
+            raise IssueNotFound(iid)
 
+        # NEVER return issue.salt
         return (issue.title, issue.description, issue.type,
                 self.json2kv(issue.kv))
 
@@ -191,6 +225,11 @@ class Election:
 
         self.c_delete_issue.perform(iid)
 
+        # If the issue didn't exist, we deleted nothing.
+        if self.c_delete_issue.rowcount == 0:
+            raise IssueNotFound(iid)
+        # else .rowcount == 1
+
     def list_issues(self):
         "Return ordered EasyDicgt<IID, TITLE, DESCRIPTION, TYPE, KV> for all 
ISSUES."
 
@@ -220,9 +259,7 @@ class Election:
         "Add VOTESTRING as the (latest) vote by PID for IID."
 
         # The Election should be open.
-        assert self.is_open()
-
-        md = self.q_metadata.first_row(self.eid)
+        md = self._all_metadata(self.S_OPEN)
 
         ### validate VOTESTRING for ISSUE.TYPE voting
 
@@ -246,9 +283,7 @@ class Election:
         """
 
         # The Election should be closed.
-        assert self.is_closed()
-
-        md = self.q_metadata.first_row(self.eid)
+        md = self._all_metadata(self.S_CLOSED)
 
         # Need the issue TYPE
         issue = self.q_get_issue.first_row(iid)
@@ -286,9 +321,7 @@ class Election:
         "Return {ISSUE-ID: BOOL} stating what has been voted upon."
 
         # The Election should be open.
-        assert self.is_open()
-
-        md = self.q_metadata.first_row(self.eid)
+        md = self._all_metadata(self.S_OPEN)
 
         voted_upon = { }
 
@@ -311,9 +344,7 @@ class Election:
     def is_tampered(self, pdb):
 
         # The Election should be open.
-        assert self.is_open()
-
-        md = self.q_metadata.first_row(self.eid)
+        md = self._all_metadata(self.S_OPEN)
 
         # Compute an opened_key based on the current data.
         edata = self.gather_election_data(pdb)
@@ -341,18 +372,23 @@ class Election:
     def get_state(self):
         "Derive our election state from the METADATA table."
 
-        md = self.q_metadata.first_row(self.eid)
+        return self._compute_state(self._all_metadata())
+
+    @classmethod
+    def _compute_state(cls, md):
+        "Compute Election state, given all metadata."
+
         if md.closed == 1:
             assert md.salt is not None and md.opened_key is not None
-            return self.S_CLOSED
+            return cls.S_CLOSED
         assert md.closed in (None, 0)
 
         if md.salt is None:
             assert md.opened_key is None
-            return self.S_EDITABLE
+            return cls.S_EDITABLE
         assert md.opened_key is not None
 
-        return self.S_OPEN
+        return cls.S_OPEN
 
     @staticmethod
     def kv2json(kv):
@@ -413,3 +449,37 @@ class Election:
         # Run the generator to get all rows. Returned as EasyDicts.
         db.q_owned.perform(pid,)
         return [ row for row in db.q_owned.fetchall() ]
+
+
+def not_found(cursor, id):
+    row = cursor.first_row(id)
+    return row is None
+
+
+class ElectionNotFound(Exception):
+    def __init__(self, eid):
+        self.eid = eid
+        super().__init__(str(self))
+
+    def __str__(self):
+        return f'Election[E:{self.eid}] not found'
+
+
+class ElectionBadState(Exception):
+    def __init__(self, current, required):
+        self.current = current
+        self.required = required
+        super().__init__(str(self))
+
+    def __str__(self):
+        return (f'Election[E:{self.eid}]'
+                f' is "{self.current}" but should be "{self.required}"')
+
+
+class IssueNotFound(Exception):
+    def __init__(self, iid):
+        self.iid = iid
+        super().__init__(str(self))
+
+    def __str__(self):
+        return f'Issue[I:{self.iid}] not found'

Reply via email to