diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index ce86026..8e2a7df 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -1,6 +1,8 @@ # This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python +# Updating to use `uv`. Versions pinned are current as of May 4, 2026. + name: Python application on: @@ -14,26 +16,24 @@ permissions: jobs: build: - runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.13 - uses: actions/setup-python@v3 + - uses: actions/checkout@v6 + - name: "Set up Python" + uses: actions/setup-python@v6 + with: + python-version-file: ".python-version" + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b with: - python-version: "3.13" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - pytest + # Install a specific version of uv. + version: "0.11.8" + + - name: Install the project + run: uv sync --locked --all-extras --dev + + - name: Run tests + # Run test using uv + run: uv run -m tests diff --git a/Makefile b/Makefile index 645a4c4..7938b02 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,9 @@ run: lint: uvx ruff check . +fix: + uvx ruff check . --fix + test: lint uv run -m tests diff --git a/data/roles.json b/data/roles.json new file mode 100644 index 0000000..46dbfc3 --- /dev/null +++ b/data/roles.json @@ -0,0 +1 @@ +{"roles":[{"id":3,"name":"Administrator"},{"id":4,"name":"Volunteer"},{"id":5,"name":"User"}]} \ No newline at end of file diff --git a/netbot/cog_scn.py b/netbot/cog_scn.py index 9c3e2a5..81b8176 100644 --- a/netbot/cog_scn.py +++ b/netbot/cog_scn.py @@ -6,12 +6,11 @@ from discord.commands import option, SlashCommandGroup from discord.ext import commands -from discord.utils import basic_autocomplete from redmine.model import Message, User from redmine.redmine import Client, BLOCKED_TEAM_NAME -from netbot.netbot import NetBot, default_ticket +from netbot.netbot import NetBot log = logging.getLogger(__name__) @@ -169,7 +168,7 @@ def is_admin(self, user: discord.Member) -> bool: # FIXME rename to "register"? @scn.command(description="Add a Discord user to redmine") - @option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket)) + @option("redmine_login", description="Your redmine user login") @option("member", description="Discord member collaborating with ticket", optional=True) async def add(self, ctx:discord.ApplicationContext, redmine_login:str, member:discord.Member=None): """add a Discord user to the Redmine ticketing integration""" @@ -183,7 +182,10 @@ async def add(self, ctx:discord.ApplicationContext, redmine_login:str, member:di # check the id_from_user = user.discord_id if id_from_user.id > 0: - # a valid + # a valid user + # make sure user has a volunteer role on the SCN project + project_id = 1 # "scn" + self.redmine.user_mgr.assure_project_roles(user, project_id, ["Volunteer", "User"]) await ctx.respond(f"Discord user: {discord_id} is fully configured as redmine user: {user.login}") else: # need to update @@ -200,7 +202,7 @@ async def add(self, ctx:discord.ApplicationContext, redmine_login:str, member:di await ctx.send_modal(modal) # reindex users after changes - self.redmine.user_mgr.reindex_users() + #self.redmine.user_mgr.reindex_users() @scn.command() diff --git a/netbot/cog_tickets.py b/netbot/cog_tickets.py index bc23583..c5f127a 100644 --- a/netbot/cog_tickets.py +++ b/netbot/cog_tickets.py @@ -11,7 +11,9 @@ from discord.ext import commands from discord.enums import InputTextStyle from discord.ui.item import Item, V -from discord.utils import basic_autocomplete +import discord.utils +from discord.ext import tasks + import dateparser @@ -302,11 +304,17 @@ def __init__(self, bot:NetBot): self.bot:NetBot = bot self.redmine: Client = bot.redmine - # see https://github.com/Pycord-Development/pycord/blob/master/examples/app_commands/slash_cog_groups.py ticket = SlashCommandGroup("ticket", "ticket commands") + @commands.Cog.listener() + async def on_ready(self): + # start the tasks running + self.poll_new_tickets.start() + log.info("Initialized ticket polling") + + # figure out what the term refers to # could be ticket#, team name, user name or search term def resolve_query_term(self, term) -> list[Ticket]: @@ -385,7 +393,7 @@ async def query(self, ctx: discord.ApplicationContext, term:str = ""): @ticket.command(description="Get ticket details") - @option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket)) + @option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket)) async def details(self, ctx: discord.ApplicationContext, ticket_id:int): """Update status on a ticket, using: unassign, resolve, progress""" #log.debug(f"found user mapping for {ctx.user.name}: {user}") @@ -397,7 +405,7 @@ async def details(self, ctx: discord.ApplicationContext, ticket_id:int): @ticket.command(description="Collaborate on a ticket") - @option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket)) + @option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket)) @option("member", description="Discord member collaborating with ticket", optional=True) async def collaborate(self, ctx: discord.ApplicationContext, ticket_id:int, member:discord.Member=None): """Add yourself as a collaborator on a ticket""" @@ -422,7 +430,7 @@ async def collaborate(self, ctx: discord.ApplicationContext, ticket_id:int, memb @ticket.command(description="Unassign a ticket") - @option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket)) + @option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket)) async def unassign(self, ctx: discord.ApplicationContext, ticket_id:int): """Update status on a ticket, using: unassign, resolve, progress""" # lookup the user @@ -440,7 +448,7 @@ async def unassign(self, ctx: discord.ApplicationContext, ticket_id:int): @ticket.command(description="Resolve a ticket") - @option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket)) + @option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket)) async def resolve(self, ctx: discord.ApplicationContext, ticket_id:int): """Update status on a ticket, using: unassign, resolve, progress""" # lookup the user @@ -461,7 +469,7 @@ async def resolve(self, ctx: discord.ApplicationContext, ticket_id:int): @ticket.command(description="Mark a ticket in-progress") - @option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket)) + @option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket)) @option("member", description="Discord member taking ownership", optional=True) async def progress(self, ctx: discord.ApplicationContext, ticket_id:int, member:discord.Member=None): """Update status on a ticket, using: progress""" @@ -488,7 +496,7 @@ async def progress(self, ctx: discord.ApplicationContext, ticket_id:int, member: @ticket.command(description="Assign a ticket") - @option("ticket_id", description="ticket ID", autocomplete=basic_autocomplete(default_ticket)) + @option("ticket_id", description="ticket ID", autocomplete=discord.utils.basic_autocomplete(default_ticket)) @option("member", description="Discord member taking ownership", optional=True) async def assign(self, ctx: discord.ApplicationContext, ticket_id:int, member:discord.Member=None): # lookup the user @@ -520,17 +528,16 @@ async def assign(self, ctx: discord.ApplicationContext, ticket_id:int, member:di # await ctx.respond(f"EDIT #{ticket.id}", view=EditView(self.bot)) - async def create_thread(self, ticket:Ticket, ctx:discord.ApplicationContext): - log.info(f"creating a new thread for ticket #{ticket.id} in channel: {ctx.channel.name}") - thread_name = f"Ticket #{ticket.id}: {ticket.subject}" - if isinstance(ctx.channel, discord.Thread): - log.debug(f"creating thread in parent channel {ctx.channel.parent.name}, for {ticket}") - thread = await ctx.channel.parent.create_thread(name=thread_name, type=discord.ChannelType.public_thread) + async def create_thread(self, ticket:Ticket, channel): + if isinstance(channel, discord.Thread): + log.debug(f"creating thread in parent channel {channel.parent.name}, for {ticket}") + thread = await self.create_ticket_thread(ticket, channel.parent) + elif isinstance(channel, discord.TextChannel): + log.debug(f"creating thread in channel {channel.name}, for {ticket}") + thread = await self.create_ticket_thread(ticket, channel) else: - log.debug(f"creating thread in channel {ctx.channel.name}, for {ticket}") - thread = await ctx.channel.create_thread(name=thread_name, type=discord.ChannelType.public_thread) - # ticket-614: Creating new thread should post the ticket details to the new thread - await thread.send(self.bot.formatter.format_ticket_details(ticket)) + log.warning(f"Unrecognized channel type: {type(channel)}, {channel}") + return thread @@ -566,7 +573,7 @@ async def create_new_ticket(self, ctx: discord.ApplicationContext, title:str): log.debug(f"creating ticket in {channel_name} for tracker={tracker}, owner={team}") ticket = self.redmine.ticket_mgr.create(user, message, tracker_id=tracker.id, assigned_to_id=team.id) # create new ticket thread - thread = await self.create_thread(ticket, ctx) + thread = await self.create_thread(ticket, ctx.channel) # use to send notification for team/role ticket_link = self.bot.formatter.redmine_link(ticket) alert_msg = f"New ticket created: {ticket_link}" @@ -605,7 +612,7 @@ async def notify(self, ctx: discord.ApplicationContext, message:str = ""): @ticket.command(description="Thread a Redmine ticket in Discord") - @option("ticket_id", description="ID of tick to create thread for") + @option("ticket_id", description="ID of ticket to create thread for") async def thread(self, ctx: discord.ApplicationContext, ticket_id:int): ticket = self.redmine.ticket_mgr.get(ticket_id) if ticket: @@ -626,7 +633,7 @@ async def thread(self, ctx: discord.ApplicationContext, ticket_id:int): # fall thru to create thread and sync # create the thread... - thread = await self.create_thread(ticket, ctx) + thread = await self.create_thread(ticket, ctx.channel) # update the discord flag on tickets, add a note with url of thread; thread.jump_url name = thread.name @@ -642,7 +649,7 @@ async def thread(self, ctx: discord.ApplicationContext, ticket_id:int): @ticket.command(name="tracker", description="Update the tracker of a ticket") - @option("ticket_id", description="ID of ticket to update", autocomplete=basic_autocomplete(default_ticket)) + @option("ticket_id", description="ID of ticket to update", autocomplete=discord.utils.basic_autocomplete(default_ticket)) @option("tracker", description="Tracker to assign to ticket", autocomplete=get_trackers) async def tracker(self, ctx: discord.ApplicationContext, ticket_id:int, tracker:str): user = self.redmine.user_mgr.find_discord_user(ctx.user.name) @@ -665,7 +672,7 @@ async def tracker(self, ctx: discord.ApplicationContext, ticket_id:int, tracker: @ticket.command(name="status", description="Update the status of a ticket") - @option("ticket_id", description="ID of ticket to update", autocomplete=basic_autocomplete(default_ticket)) + @option("ticket_id", description="ID of ticket to update", autocomplete=discord.utils.basic_autocomplete(default_ticket)) @option("status", description="Status to assign to ticket", autocomplete=get_statuses) async def status(self, ctx: discord.ApplicationContext, ticket_id:int, status:str): user = self.redmine.user_mgr.find_discord_user(ctx.user.name) @@ -688,7 +695,7 @@ async def status(self, ctx: discord.ApplicationContext, ticket_id:int, status:st @ticket.command(name="priority", description="Update the priority of a ticket") - @option("ticket_id", description="ID of ticket to update", autocomplete=basic_autocomplete(default_ticket)) + @option("ticket_id", description="ID of ticket to update", autocomplete=discord.utils.basic_autocomplete(default_ticket)) @option("priority", description="Priority to assign to ticket", autocomplete=get_priorities) async def priority(self, ctx: discord.ApplicationContext, ticket_id:int, priority:str): user = self.redmine.user_mgr.find_discord_user(ctx.user.name) @@ -715,7 +722,7 @@ async def priority(self, ctx: discord.ApplicationContext, ticket_id:int, priorit @ticket.command(name="subject", description="Update the subject of a ticket") - @option("ticket_id", description="ID of ticket to update", autocomplete=basic_autocomplete(default_ticket)) + @option("ticket_id", description="ID of ticket to update", autocomplete=discord.utils.basic_autocomplete(default_ticket)) @option("subject", description="Updated subject") async def subject(self, ctx: discord.ApplicationContext, ticket_id:int, subject:str): user = self.redmine.user_mgr.find_discord_user(ctx.user.name) @@ -885,3 +892,79 @@ async def recordTime(self, ctx: discord.ApplicationContext, hours: float, progra redmine.ticket_mgr.resolve(ticket_id) program = ctx.bot.redmine.ticket_mgr.get_program_by_id(program_id) await ctx.respond(f"Recorded **{hours} hours** on **{program}** for *{user.discord}*") + + + ### Ticket autothreading and notification ### + + AUTOTHREAD_TRACKERS = ["Mutual-Aid-Action"] + AUTONOTIFY_TRACKERS = ["Mutual-Aid-Action"] + + + @tasks.loop(minutes=1.0) + async def poll_new_tickets(self): + log.debug("notify_new_tickets. this should be called every minute.") + + # check for new tickets + for ticket in self.get_new_tickets(): + if ticket.tracker.name in self.AUTOTHREAD_TRACKERS: + log.debug(f"auto-threading ticket {ticket.id} based on tracker: {ticket.tracker}") + await self.sync_ticket(ticket) + + if ticket.tracker.name in self.AUTONOTIFY_TRACKERS: + # no need to await the notification + await self.notify_ticket(ticket) + + + def get_new_tickets(self): + log.debug("Checking for new tickets") + # for now, tickets created in the last 5 minutes + ## FIXME - track and manage queries, state in netbot for last query + timestamp = synctime.now() - dt.timedelta(minutes=5) + tickets = self.redmine.ticket_mgr.new_tickets_since(timestamp) + return tickets + + + ### returns true only when a discord thread is created. + async def sync_ticket(self, ticket: Ticket) -> bool: + # first, check if sync currently exists + # possible TODO: cache of ticket id -> thread ids + channel_id = ticket.channel_id + if channel_id == 0: + # find parent channel for thread + parent = self.bot.channel_for_ticket(ticket) + # create thread + thread = await self.create_ticket_thread(ticket, parent) + if thread: + # create sync record + sync_rec = synctime.SyncRecord(ticket.id, thread.id) + self.redmine.ticket_mgr.update_sync_record(sync_rec) + # sync + complete = await self.bot.sync_thread(thread) + return complete + else: + log.error(f"No update. Cannot find channel for {ticket}") + return False + else: + thread = self.bot.channel_for_ticket(ticket) + _ = await self.bot.sync_thread(thread) + return False + + + async def notify_ticket(self, ticket:Ticket) -> None: + log.info(f"FIXME: Notify ticket: {ticket}") + + + async def create_ticket_thread(self, ticket: Ticket, parent_channel: discord.TextChannel) -> discord.Thread: + if parent_channel: + log.info(f"creating a new thread for ticket #{ticket.id} in channel: {parent_channel}") + thread_name = f"Ticket #{ticket.id}: {ticket.subject}" + + log.debug(f"creating thread in channel {parent_channel.name}, for {ticket}") + thread = await parent_channel.create_thread(name=thread_name, type=discord.ChannelType.public_thread) + + # ticket-614: Creating new thread should post the ticket details to the new thread + await thread.send(self.bot.formatter.format_ticket_details(ticket)) + + return thread + else: + log.warning(f"Empty parent_channel provided for threading ticket {ticket}") diff --git a/netbot/netbot.py b/netbot/netbot.py index a3a2b68..12d883e 100755 --- a/netbot/netbot.py +++ b/netbot/netbot.py @@ -238,7 +238,7 @@ def append_redmine_note(self, ticket, message: discord.Message) -> None: self.redmine.ticket_mgr.append_message(ticket.id, user_login=None, note=formatted) - async def synchronize_ticket(self, ticket, thread:discord.Thread) -> bool: + async def synchronize_ticket(self, ticket:Ticket, thread:discord.Thread) -> bool: """ Synchronize a ticket to a thread returns True after a sucessful sync or if there are no changes, false if a sync is in progress. @@ -357,8 +357,6 @@ async def sync_thread(self, thread:discord.Thread): return ticket else: raise NetbotException(f"Ticket {ticket.id} is locked for syncronization.") - else: - log.debug(f"no ticket found for {thread.name}") return None @@ -388,8 +386,8 @@ async def sync_all_threads(self): # ticket is locked. # skip gracefully log.debug(str(ex)) - except Exception: - log.exception(f"Error syncing {thread}") + except Exception as ex: + log.exception(f"Error syncing {thread}: {ex}") def channel_for_ticket(self, ticket: Ticket) -> discord.TextChannel: diff --git a/redmine/model.py b/redmine/model.py index bd5c7c1..5dd2aa6 100644 --- a/redmine/model.py +++ b/redmine/model.py @@ -206,8 +206,29 @@ def add_user(self, teamname:str, username:str, userid:int) -> None: team.add_user(NamedId(userid, username)) -DISCORD_ID_FIELD = "Discord ID" +# 'memberships': [ +# { 'id': 1674, +# 'project': {'id': 1, 'name': 'Seattle Community Network'}, +# 'roles': [ +# {'id': 4, 'name': 'Volunteer'}, +# {'id': 4, 'name': 'Volunteer', 'inherited': True}, +# {'id': 4, 'name': 'Volunteer', 'inherited': True}, +# {'id': 3, 'name': 'Administrator'}, +# {'id': 5, 'name': 'User'}]}]}} + + +@dataclass +class Membership(): + id: int + project: NamedId + roles: list[NamedId] + def __post_init__(self): + self.project = NamedId(**self.project) + self.roles = [NamedId(id=role['id'], name=role['name']) for role in self.roles] + + +DISCORD_ID_FIELD = "Discord ID" @dataclass class User(): @@ -225,13 +246,15 @@ class User(): last_login_on: dt.datetime passwd_changed_on: dt.datetime twofa_scheme: str + custom_fields: list[CustomField] + memberships: list[Membership] | None = None api_key: str = "" status: int = 0 - custom_fields: list[CustomField] - def __post_init__(self): self.custom_fields = [CustomField(**field) for field in self.custom_fields] + if self.memberships: + self.memberships = [Membership(**membership) for membership in self.memberships] self.discord_id = self.parse_discord_custom_field() @@ -296,6 +319,17 @@ def asdict(self): def json(self): return json.dumps(self.asdict(), indent=4, default=vars) + # Return a list of role IDs for the given project + def project_roles(self, project_id: int) -> list[int]: + roles = [] + if self.memberships: + for membership in self.memberships: + if membership.project.id == project_id: + for role in membership.roles: + if role.id not in roles: + roles.append(role.id) + return roles + @dataclass class UserResult: @@ -498,6 +532,20 @@ def get_sync_record(self) -> synctime.SyncRecord | None: return record + @property + def channel_id(self) -> int: + """ + IFF there is a valid sync record attached to the ticket, + return the channel_id of the sync record. + Otherwise, 0. + """ + sync_rec = self.get_sync_record() + if sync_rec: + return sync_rec.channel_id + else: + return 0 + + def validate_sync_record(self, expected_channel: int = 0) -> synctime.SyncRecord | None: # Parse custom_field into datetime # lookup field by name diff --git a/redmine/synctime.py b/redmine/synctime.py index f738557..c2792fa 100644 --- a/redmine/synctime.py +++ b/redmine/synctime.py @@ -68,7 +68,7 @@ def zulu(timestamp:dt.datetime) -> str: class SyncRecord(): """encapulates the record of the last ticket syncronization""" - def __init__(self, ticket_id: int, channel_id: int, last_sync: dt.datetime): + def __init__(self, ticket_id: int, channel_id: int, last_sync: dt.datetime = now()): assert last_sync.tzinfo is dt.timezone.utc # make sure TZ is set and correct assert last_sync.timestamp() > 0 self.ticket_id = ticket_id @@ -112,4 +112,4 @@ def token_str(self) -> str: def __str__(self) -> str: - return f"SYNC #{self.ticket_id} <-> {self.channel_id}, {age_str(self.last_sync)}" + return f"SYNC #{self.ticket_id} <-> {self.channel_id}, synced {age_str(self.last_sync)} ago" diff --git a/redmine/tickets.py b/redmine/tickets.py index c499872..0e50fd0 100644 --- a/redmine/tickets.py +++ b/redmine/tickets.py @@ -116,9 +116,13 @@ def load_priorities(self) -> dict[str,NamedId]: priorities: dict[str,NamedId] = {} resp = self.session.get("/enumerations/issue_priorities.json") - for priority in reversed(resp['issue_priorities']): - if priority.get('active', False): - priorities[priority['name']] = NamedId(priority['id'], priority['name']) + + if resp: + for priority in reversed(resp['issue_priorities']): + if priority.get('active', False): + priorities[priority['name']] = NamedId(priority['id'], priority['name']) + else: + log.warning("Unable to load priority enumeration") return priorities @@ -465,17 +469,18 @@ def most_recent_ticket_for(self, user:User) -> Ticket: return None - def new_tickets_since(self, timestamp:dt.datetime): + def new_tickets_since(self, timestamp:dt.datetime) -> list[Ticket]: """get new tickets since provided timestamp""" # query for new tickets since date # To fetch issues created after a certain timestamp (uncrypted filter is ">=2014-01-02T08:12:32Z") : # GET /issues.xml?created_on=%3E%3D2014-01-02T08:12:32Z - timestr = dt.datetime.isoformat(timestamp) # time-format. + timestr = synctime.zulu(timestamp) query = f"/issues.json?created_on=%3E%3D{timestr}&sort={DEFAULT_SORT}&limit=100" response = self.session.get(query) - - if response.total_count > 0: - return response.issues + if response: + result = TicketsResult(**response) + log.debug(f"new_tickets_since: {result}") + return result.issues else: log.debug(f"No tickets created since {timestamp}") return None @@ -578,9 +583,8 @@ def enable_discord_sync(self, ticket_id:int, user:User, note:str) -> Ticket: "cf_1": "1", # TODO: lookup in self.get_field_id } - return self.update(ticket_id, fields, user.login) - # currently doesn't return or throw anything - # todo: better error reporting back to discord + user_login = user.login if user is not None else None + return self.update(ticket_id, fields, user_login) def assign_ticket(self, ticket_id, user:User, user_id=None): diff --git a/redmine/users.py b/redmine/users.py index 0a05d39..fdb627e 100644 --- a/redmine/users.py +++ b/redmine/users.py @@ -5,6 +5,8 @@ import logging import json +import urllib + from redmine.model import Team, User, UserResult, NamedId, DISCORD_ID_FIELD from redmine.session import RedmineSession, RedmineException @@ -15,6 +17,7 @@ USER_RESOURCE = "/users.json" TEAM_RESOURCE = "/groups.json" +ROLES_RESOURCE = "/roles.json" BLOCKED_TEAM_NAME = "blocked" @@ -26,6 +29,7 @@ def __init__(self): self.user_emails: dict[str, int] = {} self.discord_ids: dict[str, int] = {} self.teams: dict[str, Team] = {} + self.roles: dict[str, int] = {} # role name, id def clear(self): @@ -34,6 +38,7 @@ def clear(self): self.user_ids.clear() self.user_emails.clear() self.discord_ids.clear() + self.roles.clear() def cache_user(self, user: User) -> None: @@ -121,6 +126,10 @@ def is_user_in_team(self, user:User, teamname:str) -> bool: return False + def lookup_role(self, role_name:str) -> int: # role_id in redmine + return self.roles.get(role_name) + + class UserManager(): """manage redmine users""" session: RedmineSession @@ -287,18 +296,18 @@ def find_discord_user(self, discord_user_id:str) -> User: # just a proxy return self.cache.find_discord_user(discord_user_id) - #def is_user_or_group(self, term:str): - # return self.cache.is_user_or_group(term) def is_team(self, name:str): return self.cache.is_team(name) - def get(self, user_id:int): + + def get(self, user_id:int, **params) -> User|None: """get a user by ID, directly from redmine""" - jresp = self.session.get(f"/users/{user_id}.json") - if jresp: - #log.info(f"^^^ {jresp['user']}") - return User(**jresp['user']) + query = f"/users/{user_id}.json?{urllib.parse.urlencode(params)}" + response = self.session.get(query) + if response: + #log.debug(f"USER: {response}") + return User(**response['user']) # used only in testing @@ -324,6 +333,11 @@ def create_discord_mapping(self, user:User, discord_id: int, discord_name:str) - ] } updated = self.update(user, fields) + + # make sure user has a volunteer role on the SCN project + project_id = 1 # FIXME: lookup_project("scn") + self.assure_project_roles(user, project_id, ["Volunteer", "User"]) + # cache updated user, based on now-or-changed discord id. self.cache.cache_user(updated) return updated @@ -498,4 +512,65 @@ def reindex(self): start = dt.datetime.now() self.reindex_users() self.reindex_teams() + self.reindex_roles() log.info(f"reindex took {dt.datetime.now() - start}") + + + def assure_project_roles(self, user: User, project_id: int, role_names: list[str]): + # get the user info, with memberships + user = self.get(user.id, include="memberships") + current_roles = user.project_roles(project_id) # this only works if "memberships" is populated, as above + roles = [] + for role_name in role_names: + role_id = self.cache.lookup_role(role_name) + if role_id: + if role_id in current_roles: + log.info(f"Skipping existing role: {user.login} -> {project_id}, role={role_name}, id={role_id}") + else: + roles.append(role_id) + else: + log.error(f"assure_project_role {user.login} {project_id} UNKNOWN ROLE: {role_name}") + + if len(roles) == 0: + log.info(f"No roles to add for {user.login} {project_id}") + return + + # https://www.redmine.org/projects/redmine/wiki/Rest_Memberships#POST + # POST /projects/{project_name}/memberships.json + data = { + 'membership': { + "user_id": user.id, + "role_ids": roles, + } + } + + # The project identifier, "scn", can be used directly in the url. + url = f"/projects/{project_id}/memberships.json" + response = self.session.post(url, data=json.dumps(data)) + + # check status + if response: + log.info(f"created {user.login} {project_id} {role_names}: {response}") + else: + raise RedmineException(f"assure_project_role {user.login} {project_id} {role_name} failed", + response.headers['X-Request-Id']) + + + def get_all_roles(self) -> dict[str, int]: + roles = {} + + response = self.session.get(ROLES_RESOURCE) + if response: + for item in response['roles']: + roles[item['name']] = item['id'] + + return roles + + + def reindex_roles(self): + all_roles = self.get_all_roles() + if all_roles: + self.cache.roles = all_roles + log.debug(f"Roles: {self.cache.roles}") + else: + log.warning("No roles to index") diff --git a/tests/mock_session.py b/tests/mock_session.py index b7b8437..d0e0e43 100644 --- a/tests/mock_session.py +++ b/tests/mock_session.py @@ -102,8 +102,15 @@ def put(self, resource:str, data:str, impersonate_id:str|None=None) -> None: # log.debug(f"PUT {path} -> {item}") - # def post(self, resource: str, data:str, user_login: str|None = None, files: list|None = None) -> dict|None: - # log.info(f"POST {resource}, data={data} user_login={user_login}") + def post(self, resource: str, data:str, user_login: str|None = None, files: list|None = None) -> dict|None: + log.info(f"POST {resource}, data={data} user_login={user_login}") + return { + "status code": "200", + "headers": { + 'X-Request-Id': __name__ + } + } + # TODO: Store state as a blob under that resource and return it later # item_id = self._next_id() # path = urlparse(resource).path diff --git a/tests/test_users.py b/tests/test_users.py index 20db326..630f042 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -10,6 +10,7 @@ log = logging.getLogger(__name__) + class TestUserManager(test_utils.MockRedmineTestCase): """Mocked testing of user manager""" @@ -42,4 +43,9 @@ def test_create_discord_mapping(self, mock_put:AsyncMock, mock_get:AsyncMock): self.assertEqual(discord_id, updated.discord_id.id) self.assertEqual(discord_name, updated.discord_id.name) - mock_get.assert_called_once() + mock_get.assert_called() + + + def test_role_cache(self): + found_id = self.user_mgr.cache.lookup_role("Volunteer") + self.assertEqual(found_id, 4) diff --git a/threader/imap.py b/threader/imap.py index 33111fd..09137c0 100755 --- a/threader/imap.py +++ b/threader/imap.py @@ -178,6 +178,10 @@ def handle_message(self, msg_id:str, message:Message): subject = message.subject_cleaned() log.debug(f'uid:{msg_id} - from:{last}, {first}, email:{addr}, subject:{subject}') + # NOTE: Might need a setting to override "search for matching ticket" + # Simplyfying assumption: If the subject has a valid tracker tag -> [Valid-Tracker-Name] + # Then skip the searches in first, next. + ticket = None # first, search for a matching subject tickets = self.redmine.ticket_mgr.match_subject(subject)