Skip to content

Commit

Permalink
Merge pull request #19 from Local-Connectivity-Lab/ticket-788
Browse files Browse the repository at this point in the history
Ticket 788 - Add default project to .env file
  • Loading branch information
philion authored Apr 27, 2024
2 parents 066b934 + 17851e8 commit 369a664
Show file tree
Hide file tree
Showing 6 changed files with 157 additions and 32 deletions.
4 changes: 4 additions & 0 deletions model.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ def full_name(self) -> str:
def __str__(self):
return f"#{self.id} {self.full_name()} login={self.login} discord={self.discord_id}"

def set_field(self, fieldname:str, value):
setattr(self, fieldname, value)
log.debug(f"@{self.login}: {fieldname} <= {value}")


@dataclass
class UserResult:
Expand Down
26 changes: 18 additions & 8 deletions redmine.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from session import RedmineSession
from model import Message, Ticket, User
from users import UserManager
from tickets import TicketManager
from tickets import TicketManager, SCN_PROJECT_ID


log = logging.getLogger(__name__)
Expand All @@ -23,7 +23,6 @@
SYNC_FIELD_NAME = "syncdata"
DISCORD_ID_FIELD = "Discord ID"
BLOCKED_TEAM_NAME = "blocked"
SCN_PROJECT_ID = 1 # could lookup scn in projects
STATUS_REJECT = 5 # could to status lookup, based on "reject"


Expand All @@ -36,14 +35,22 @@ def __init__(self, message: str, request_id: str = "-") -> None:

class Client():
"""redmine client"""
def __init__(self, session:RedmineSession):
def __init__(self, session:RedmineSession, user_mgr:UserManager, ticket_mgr:TicketManager):
self.url = session.url
self.user_mgr:UserManager = UserManager(session)
self.ticket_mgr:TicketManager = TicketManager(session)
self.user_mgr = user_mgr
self.ticket_mgr = ticket_mgr

self.user_mgr.reindex() # build the cache when starting


@classmethod
def from_session(cls, session:RedmineSession, default_project:int):
user_mgr = UserManager(session)
ticket_mgr = TicketManager(session, default_project=default_project)

return cls(session, user_mgr, ticket_mgr)


@classmethod
def fromenv(cls):
url = os.getenv('REDMINE_URL')
Expand All @@ -54,7 +61,9 @@ def fromenv(cls):
if token is None:
raise RedmineException("Unable to load REDMINE_TOKEN")

return cls(RedmineSession(url, token))
default_project = os.getenv("DEFAULT_PROJECT_ID", default=SCN_PROJECT_ID)

return cls.from_session(RedmineSession(url, token), default_project)


def create_ticket(self, user:User, message:Message) -> Ticket:
Expand Down Expand Up @@ -134,7 +143,7 @@ def get_notes_since(self, ticket_id, timestamp=None):
def enable_discord_sync(self, ticket_id, user, note):
fields = {
"note": note, #f"Created Discord thread: {thread.name}: {thread.jump_url}",
"cf_1": "1",
"cf_1": "1", # TODO: read from custom fields via
}

self.update_ticket(ticket_id, fields, user.login)
Expand Down Expand Up @@ -168,7 +177,8 @@ def update_sync_record(self, record:synctime.SyncRecord):
log.debug(f"Updating sync record in redmine: {record}")
fields = {
"custom_fields": [
{ "id": 4, "value": record.token_str() } # cf_4, custom field syncdata, #TODO search for it
{ "id": 4, "value": record.token_str() } # cf_4, custom field syncdata,
#TODO search for custom field ID with TicketManager.get_field_id
]
}
self.update_ticket(record.ticket_id, fields)
Expand Down
2 changes: 1 addition & 1 deletion session.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def get(self, query_str:str, impersonate_id:str|None=None):
if r.ok:
return r.json()
else:
log.info(f"GET {r.reason}/{r.status_code} url={r.request.url}, reqid={r.headers['X-Request-Id']}")
log.info(f"GET {r.reason}/{r.status_code} url={r.request.url}, reqid={r.headers.get('X-Request-Id','')}")
except (TimeoutError, ConnectTimeoutError, ConnectTimeout, ConnectionError):
# ticket-509: Handle timeout gracefully
log.warning(f"TIMEOUT ({TIMEOUT}s) during {query_str}")
Expand Down
34 changes: 24 additions & 10 deletions test_tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,22 @@

import unittest
import logging
import json
from unittest.mock import MagicMock, patch

from dotenv import load_dotenv

from tickets import TicketManager

import test_utils


log = logging.getLogger(__name__)

class TestTicketManager(unittest.TestCase):
class TestTicketManager(test_utils.MockRedmineTestCase):
"""Mocked testing of ticket manager"""
def mock_mgr(self):
return TicketManager(test_utils.mock_session())

@patch('tickets.TicketManager.load_custom_fields')
@patch('session.RedmineSession.get')
def test_expired_tickets(self, mock_get:MagicMock, mock_cf:MagicMock):
# setup custom fields
mock_cf.return_value = test_utils.custom_fields() # TODO move to mgr setup

def test_expired_tickets(self, mock_get:MagicMock):
# setup the mock tickets
ticket = test_utils.mock_ticket()
result = test_utils.mock_result([
Expand All @@ -33,13 +27,33 @@ def test_expired_tickets(self, mock_get:MagicMock, mock_cf:MagicMock):
])
mock_get.return_value = result.asdict()

expired = self.mock_mgr().expired_tickets()
expired = self.tickets_mgr.expired_tickets()
self.assertGreater(len(expired), 0)
expired_ids = [ticket.id for ticket in expired]
self.assertIn(ticket.id, expired_ids)
#FIXME mock_get.assert_called_once()


@patch('session.RedmineSession.post')
def test_default_project_id(self, mock_post:MagicMock):
test_proj_id = "42"

msg = self.create_message()
# note to future self: mock response to create should have content of crete request

# setup the mock tickets
ticket = test_utils.mock_ticket()
mock_post.return_value = { "issue": ticket.asdict() }

self.tickets_mgr.create(self.user, msg, test_proj_id)

resp_ticket = json.loads(mock_post.call_args[0][1])["issue"]
#print("###", type(resp_ticket), resp_ticket)

mock_post.assert_called_once()
self.assertEqual(test_proj_id, resp_ticket['project_id'])


# The integration test suite is only run if the ENV settings are configured correctly
@unittest.skipUnless(load_dotenv(), "ENV settings not available")
class TestIntegrationTicketManager(test_utils.RedmineTestCase):
Expand Down
97 changes: 92 additions & 5 deletions test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
from discord import ApplicationContext
import model
from users import UserManager
from model import Message, User
from model import Message, User, NamedId
from tickets import SCN_PROJECT_ID, TicketManager
import session
from redmine import Client

Expand Down Expand Up @@ -82,12 +83,31 @@ def mock_ticket(**kwargs) -> model.Ticket:
return ticket


def mock_user(tag: str) -> model.User:
return model.User(
id=5,
login=f'test-{tag}',
mail=f'{tag}@example.org',
custom_fields=[ {"id": 2, "name":'Discord ID', "value":f'discord-{tag}'} ],
admin=False,
firstname='Test',
lastname=tag,
created_on='2020-07-29T00:37:38Z',
updated_on='2020-02-21T02:14:12Z',
last_login_on='2020-03-31T01:56:05Z',
passwd_changed_on='2020-09-24T18:41:08Z',
twofa_scheme=None,
api_key='',
status=''
)


def mock_result(tickets: list[model.Ticket]) -> model.TicketsResult:
return model.TicketsResult(len(tickets), 0, 25, tickets)


def mock_session() -> session.RedmineSession:
return session.RedmineSession("","")
return session.RedmineSession("http://example.com", "TeStInG-TOK-3N")


def custom_fields() -> dict:
Expand All @@ -104,13 +124,74 @@ def remove_test_users(user_mgr:UserManager):
# TODO delete test tickets and "Search for subject match in email threading" ticket. TAG with test too?


class MockUserManager(UserManager):
"""mock"""
def get_all(self) -> list[User]:
return []

def get_all_teams(self, include_users: bool = True) -> dict[str, model.Team]:
return {}


class MockTicketManager(TicketManager):
"""mock"""
def __init__(self, sess: session.RedmineSession):
super().__init__(sess, default_project=SCN_PROJECT_ID )


def load_custom_fields(self) -> dict[str,NamedId]:
""" override load_custom_fields to load expected custom fields. """
result = {}
with open('test/custom-fields.json', "r", encoding="utf-8") as fields_file:
fields = json.load(fields_file)
for name, value in fields.items():
result[name] = NamedId(**value)

log.debug(f"loading custom fields for test: {result}")
return result


def create_mock_redmine():
sess = mock_session()
user_mgr = MockUserManager(sess)
ticket_mgr = MockTicketManager(sess)
return Client(sess, user_mgr, ticket_mgr)


class MockRedmineTestCase(unittest.TestCase):
"""Abstract base class for mocked redmine testing"""

@classmethod
def setUpClass(cls):
cls.redmine = create_mock_redmine()
cls.user_mgr = cls.redmine.user_mgr
cls.tickets_mgr = cls.redmine.ticket_mgr
cls.tag:str = tagstr()

cls.user:User = mock_user(cls.tag)
cls.user_mgr.cache.cache_user(cls.user)
log.info(f"SETUP created mock user: {cls.user}")


def create_message(self) -> model.Message:
subject = f"TEST {self.tag} {unittest.TestCase.id(self)}"
text = f"This is a ticket for {unittest.TestCase.id(self)} with {self.tag}."
message = Message(self.user.mail, subject, f"to-{self.tag}@example.com", f"cc-{self.tag}@example.com")
message.set_note(text)
return message


def create_ticket(self) -> model.Ticket:
return self.redmine.create_ticket(self.user, self.create_message())


class RedmineTestCase(unittest.TestCase):
"""Abstract base class for testing redmine features"""

@classmethod
def setUpClass(cls):
sess = session.RedmineSession.fromenv()
cls.redmine = Client(sess)
cls.redmine = Client.from_session(sess, SCN_PROJECT_ID)
cls.user_mgr = cls.redmine.user_mgr
cls.tickets_mgr = cls.redmine.ticket_mgr
cls.tag:str = tagstr()
Expand Down Expand Up @@ -149,7 +230,7 @@ def build_context(self) -> ApplicationContext:


def audit_expected_values():
redmine = Client(session.RedmineSession.fromenv())
redmine = Client.from_session(session.RedmineSession.fromenv(), SCN_PROJECT_ID)

# audit checks...
# 1. make sure admin use exists
Expand All @@ -176,6 +257,12 @@ def audit_expected_values():
# construct the client and run the email check
#client = session.RedmineSession.fromenv()
#users = UserManager(client)

#user = users.get_by_name("philion")

#with open('test/test-user.json', 'w') as f:
# json.dump(user, f)

#remove_test_users(users)

audit_expected_values()
#audit_expected_values()
26 changes: 18 additions & 8 deletions tickets.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
ISSUES_RESOURCE="/issues.json"
ISSUE_RESOURCE="/issues/"
DEFAULT_SORT = "status:desc,priority:desc,updated_on:desc"
SCN_PROJECT_ID = 1 # could lookup scn in projects
SCN_PROJECT_ID = "1" # could lookup scn in projects
INTAKE_TEAM = "ticket-intake"
INTAKE_TEAM_ID = 19 # FIXME

Expand All @@ -31,9 +31,10 @@

class TicketManager():
"""manage redmine tickets"""
def __init__(self, session: RedmineSession):
def __init__(self, session: RedmineSession, default_project):
self.session: RedmineSession = session
self.custom_fields = self.load_custom_fields()
self.default_project = default_project


def load_custom_fields(self) -> dict[str,NamedId]:
Expand Down Expand Up @@ -62,20 +63,28 @@ def load_trackers(self) -> dict[str,NamedId]:


def get_field_id(self, name:str) -> int | None:
if not self.custom_fields:
log.warning(f"Custom field '{name}' requested, none available")
return None

if name in self.custom_fields:
return self.custom_fields[name].id
return None


def create(self, user: User, message: Message) -> Ticket:
def create(self, user: User, message: Message, project_id: int = None) -> Ticket:
"""create a redmine ticket"""
# https://www.redmine.org/projects/redmine/wiki/Rest_Issues#Creating-an-issue
# would need full param handling to pass that thru discord to get to this invocation
# this would be resolved by a Ticket class to emcapsulate.

# check default project
if not project_id:
project_id = self.default_project

data = {
'issue': {
'project_id': SCN_PROJECT_ID, #FIXME hard-coded project ID MOVE project ID to API
'project_id': project_id,
'subject': message.subject,
'description': message.note,
# ticket-485: adding custom field for To//Cc headers.
Expand Down Expand Up @@ -227,7 +236,7 @@ def expire(self, ticket:Ticket):
# Ideally, this would @ the owner and collaborators.
fields = {
"assigned_to_id": INTAKE_TEAM_ID,
"status_id": "1", # New
"status_id": "1", # New, TODO lookup using status lookup table.
"notes": f"Ticket automatically expired after {TICKET_MAX_AGE} days due to inactivity.",
}
self.update(ticket.id, fields)
Expand Down Expand Up @@ -409,7 +418,7 @@ def get_notes_since(self, ticket_id:int, timestamp:dt.datetime=None) -> list[Tic
def enable_discord_sync(self, ticket_id, user, note):
fields = {
"note": note, #f"Created Discord thread: {thread.name}: {thread.jump_url}",
"cf_1": "1",
"cf_1": "1", # TODO: lookup in self.get_field_id
}

self.update(ticket_id, fields, user.login)
Expand Down Expand Up @@ -447,7 +456,7 @@ def reject_ticket(self, ticket_id, user_id=None) -> Ticket:
def unassign_ticket(self, ticket_id, user_id=None):
fields = {
"assigned_to_id": INTAKE_TEAM_ID,
"status_id": "1", # New
"status_id": "1", # New, TODO lookup in status table
}
self.update(ticket_id, fields, user_id)

Expand Down Expand Up @@ -490,7 +499,7 @@ def get_field(self, ticket:Ticket, fieldname:str) -> str:


def main():
ticket_mgr = TicketManager(RedmineSession.fromenv())
ticket_mgr = TicketManager(RedmineSession.fromenv(), SCN_PROJECT_ID)

#ticket_mgr.expire_expired_tickets()
#for ticket in ticket_mgr.older_than(7): #ticket_mgr.expired_tickets():
Expand All @@ -500,6 +509,7 @@ def main():

print(ticket_mgr.due())


# for testing the redmine
if __name__ == '__main__':
# load credentials
Expand Down

0 comments on commit 369a664

Please sign in to comment.