Skip to content

Commit a194a3a

Browse files
author
Paul Philion
committed
internal functions to store and get time records from redmine, including model. supporting tests
1 parent ca002d9 commit a194a3a

5 files changed

Lines changed: 192 additions & 1 deletion

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"time_entry_activities": [
3+
{
4+
"id": 15,
5+
"name": "Community Networks (General)",
6+
"is_default": true,
7+
"active": true
8+
},
9+
{
10+
"id": 16,
11+
"name": "Grant: Seattle TMF 2023-4",
12+
"is_default": false,
13+
"active": true
14+
},
15+
{
16+
"id": 17,
17+
"name": "Grant: Benton Fellowship 2024",
18+
"is_default": false,
19+
"active": true
20+
}
21+
]
22+
}

netbot/cog_tickets.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -806,3 +806,27 @@ async def parent(self, ctx: discord.ApplicationContext, parent_ticket:int):
806806
@ticket.command(name="help", description="Display help about ticket management")
807807
async def help(self, ctx: discord.ApplicationContext):
808808
await ctx.respond(embed=self.bot.formatter.help_embed(ctx))
809+
810+
811+
@discord.slash_command(name="testime", description="Record time against a program")
812+
async def recordTime(self, ctx: discord.ApplicationContext, hours: float, program: str, note: str = ""):
813+
log.info(f">>> {hours} {program} {note}")
814+
redmine: Client = ctx.bot.redmine
815+
816+
user = redmine.user_mgr.find(ctx.user.name)
817+
if not user:
818+
await ctx.respond(f"User {ctx.user.name} not mapped to redmine. Use `/scn add` to create the mapping.") # error
819+
return
820+
821+
autoresolve = False
822+
ticket_id = NetBot.parse_thread_title(ctx.channel.name)
823+
if ticket_id is None:
824+
# create a ticket and thread
825+
ticket_cog = ctx.bot.get_cog('TicketsCog')
826+
if ticket_cog:
827+
await ticket_cog.create_new_ticket(ctx, note)
828+
autoresolve = True
829+
830+
redmine.ticket_mgr.record_time(ticket_id, user, hours, program, note)
831+
if autoresolve:
832+
redmine.ticket_mgr.resolve(ticket_id)

redmine/model.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,3 +562,56 @@ def asdict(self):
562562
@property
563563
def json(self):
564564
return json.dumps(self.asdict(), indent=4, default=vars)
565+
566+
567+
@dataclass
568+
class TimeEntry:
569+
id: int
570+
project: NamedId
571+
issue: map
572+
user: NamedId
573+
activity: NamedId
574+
hours: float
575+
comments: str
576+
spent_on: dt.date
577+
created_on: dt.datetime
578+
updated_on: dt.datetime
579+
ticket_id: int = -1
580+
581+
def __post_init__(self):
582+
self.user = NamedId(**self.user)
583+
self.project = NamedId(**self.project)
584+
self.activity = NamedId(**self.activity)
585+
self.ticket_id = self.issue['id']
586+
587+
if self.created_on and isinstance(self.created_on, str):
588+
self.created_on = synctime.parse_str(self.created_on)
589+
if self.updated_on and isinstance(self.updated_on, str):
590+
self.updated_on = synctime.parse_str(self.updated_on)
591+
592+
def asdict(self):
593+
return dataclasses.asdict(self)
594+
595+
@property
596+
def json(self):
597+
return json.dumps(self.asdict(), indent=4, default=vars)
598+
599+
600+
@dataclass
601+
class TimeEntryResults:
602+
"""Encapsulates a set of tickets"""
603+
total_count: int
604+
limit: int
605+
offset: int
606+
time_entries: list[TimeEntry]
607+
608+
def __post_init__(self):
609+
if self.time_entries and len(self.time_entries) > 0 and isinstance(self.time_entries[0], dict):
610+
self.time_entries = [TimeEntry(**entry) for entry in self.time_entries]
611+
612+
def asdict(self):
613+
return dataclasses.asdict(self)
614+
615+
@property
616+
def json(self):
617+
return json.dumps(self.asdict(), indent=4, default=vars)

redmine/tickets.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import json
88
import urllib.parse
99

10-
from redmine.model import TO_CC_FIELD_NAME, User, Message, NamedId, Team, Ticket, TicketNote, TicketsResult, TicketStatus, SYNC_FIELD_NAME
10+
from redmine.model import TO_CC_FIELD_NAME, TimeEntry, TimeEntryResults, User, Message, NamedId, Team, Ticket, TicketNote, TicketsResult, TicketStatus, SYNC_FIELD_NAME
1111
from redmine.session import RedmineSession, RedmineException
1212
from redmine import synctime
1313

@@ -37,7 +37,9 @@ def __init__(self, session: RedmineSession, default_project:int):
3737
self.trackers = {}
3838
self.custom_fields = {}
3939
self.statuses = {}
40+
self.programs = {} # grants and tracked SCN projects
4041
self.default_project:int = default_project
42+
self.default_program:int = -1
4143

4244
self.reindex()
4345

@@ -47,6 +49,7 @@ def reindex(self):
4749
self.statuses = self.load_statuses()
4850
self.trackers = self.load_trackers()
4951
self.custom_fields = self.load_custom_fields()
52+
self.programs = self.load_programs()
5053

5154

5255
def sanity_check(self) -> dict[str, bool]:
@@ -76,6 +79,24 @@ def get_custom_field(self, name:str) -> NamedId | None:
7679
return self.custom_fields.get(name, None)
7780

7881

82+
def load_programs(self) -> dict[str,int]:
83+
programs: dict[str,int] = {}
84+
85+
resp = self.session.get("/enumerations/time_entry_activities.json")
86+
for activity in resp['time_entry_activities']:
87+
if activity.get('active', False): # use fallback=false for active
88+
programs[activity['name']] = activity['id']
89+
if activity.get('is_default', False):
90+
self.default_program = activity['id']
91+
92+
log.debug(f"Loaded programs: {programs}, default: {self.default_program}")
93+
return programs
94+
95+
96+
def get_program(self, program_name: str) -> int:
97+
return self.programs.get(program_name, self.default_program)
98+
99+
79100
def load_priorities(self) -> dict[str,NamedId]:
80101
"""load active priorities"""
81102

@@ -603,6 +624,50 @@ def resolve(self, ticket_id, user_id=None):
603624
return self.update(ticket_id, {"status_id": "3"}, user_id) # '3' is the status_id, it doesn't accept "Resolved"
604625

605626

627+
def record_time(self, ticket_id:int, user:User, hours:float, program:str, note:str) -> TimeEntry:
628+
# time_entry (required): a hash of the time entry attributes, including:
629+
# issue_id or project_id (only one is required): the issue id or project id to log time on (both are integers); note that project ids can only be found using the API (e.g. at /projects.json)
630+
# spent_on: the date the time was spent (default to the current date); format is e.g. 2020-12-24
631+
# hours (required): the number of spent hours
632+
# activity_id: the id of the time activity. This parameter is required unless a default activity is defined in Redmine.
633+
# comments: short description for the entry (255 characters max)
634+
# user_id: user id to be specified in need of posting time on behalf of another user
635+
time_entry = {
636+
'time_entry': {
637+
'issue_id': ticket_id,
638+
# 'spent_on': "2020-12-24", # (default to the current date)
639+
'hours': hours,
640+
'activity_id': self.get_program(program),
641+
'comments': note,
642+
'user_id': user.id,
643+
}
644+
}
645+
646+
response = self.session.post("/time_entries.json", json.dumps(time_entry), user.login)
647+
if response:
648+
return TimeEntry(**response['time_entry'])
649+
else:
650+
raise RedmineException(
651+
f"create_ticket failed, status=[{response.status_code}] {response.reason}",
652+
response.headers['X-Request-Id'])
653+
654+
655+
# project_id
656+
# issue_id
657+
# activity_id
658+
# user_id
659+
# from 2019-01-01
660+
# to 2019-01-03
661+
# spent_on - specific date?
662+
def get_time_records(self, **kwargs) -> TimeEntryResults|None:
663+
response = self.session.get(f"/time_entries.json?{urllib.parse.urlencode(kwargs)}")
664+
if response:
665+
return TimeEntryResults(**response)
666+
else:
667+
log.error(f"Error querying time records: params: {kwargs}, response={response}")
668+
return None
669+
670+
606671
def update_sync_record(self, record:synctime.SyncRecord):
607672
log.debug(f"Updating sync record in redmine: {record}")
608673
fields = {

tests/test_cog_tickets.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -583,3 +583,30 @@ async def test_parent_command(self):
583583
# delete the ticket and confirm
584584
self.redmine.ticket_mgr.remove(parent_ticket.id)
585585
self.assertIsNone(self.redmine.ticket_mgr.get(parent_ticket.id))
586+
587+
588+
async def test_record_time(self):
589+
try:
590+
ticket = self.create_test_ticket()
591+
ctx = self.build_context()
592+
ctx.channel = unittest.mock.AsyncMock(discord.TextChannel)
593+
ctx.channel.name = f"Ticket #{ticket.id}"
594+
ctx.channel.id = ticket.id
595+
596+
activity = "Development"
597+
notes = "this is a test"
598+
await self.cog.recordTime(ctx, 2.33, activity, notes)
599+
600+
records = self.redmine.ticket_mgr.get_time_records(issue_id=ticket.id)
601+
self.assertIsNotNone(records)
602+
self.assertEqual(records.total_count, 1)
603+
self.assertEqual(records.time_entries[0].project.id, 1)
604+
self.assertEqual(records.time_entries[0].ticket_id, ticket.id)
605+
self.assertEqual(records.time_entries[0].user.id, self.user.id)
606+
self.assertEqual(records.time_entries[0].activity.name, activity)
607+
self.assertEqual(records.time_entries[0].comments, notes)
608+
finally:
609+
if ticket:
610+
# delete the ticket and confirm
611+
self.redmine.ticket_mgr.remove(ticket.id)
612+
self.assertIsNone(self.redmine.ticket_mgr.get(ticket.id))

0 commit comments

Comments
 (0)