Merge branch 'dev'

Генерация отчёта для дейли для ECCM
This commit is contained in:
Pavel Gnedov 2022-11-09 18:33:49 +07:00
commit 2558908aed
31 changed files with 1278 additions and 55 deletions

3
.gitignore vendored
View file

@ -38,3 +38,6 @@ configs/issue-event-emitter-config.jsonc
tmp/*
configs/redmine-statuses-config.jsonc
configs/redmine-status-changes-config.jsonc
configs/eccm-versions-config.jsonc
configs/eccm-config.jsonc
configs/current-user-rules.jsonc

View file

@ -0,0 +1,4 @@
{
"New": "",
"*": ""
}

View file

@ -0,0 +1,11 @@
{
"currentVersions": [],
"projectName": "",
"currentIssuesStatuses": [],
"groups": [
{
"name": "",
"people": [""]
}
]
}

View file

@ -0,0 +1 @@
export const UNLIMITED = 999999;

View file

@ -65,7 +65,8 @@ export class RedmineEventsGateway {
res = await this.redmineDataLoader.loadIssues(issueNumbers);
} catch (e) {
this.logger.error(
`Error load issues: ${e.message} for issues: ${issueNumbers}`,
`Error load issues: ${e.message} ` +
`for issues: ${JSON.stringify(issueNumbers)}`,
);
return [];
}

View file

@ -1,11 +1,13 @@
import { RedmineTypes } from '../models/redmine-types';
import { Injectable, Logger } from '@nestjs/common';
import { CacheTTL, Injectable, Logger } from '@nestjs/common';
import { Issues } from '../couchdb-datasources/issues';
import { RedmineEventsGateway } from '../events/redmine-events.gateway';
import { RedmineIssuesCacheWriterService } from '../issue-cache-writer/redmine-issues-cache-writer.service';
import { RedmineDataLoader } from '../redmine-data-loader/redmine-data-loader';
import { MemoryCache } from '../utils/memory-cache';
import nano from 'nano';
import { UNLIMITED } from '../consts/consts';
import { GetParentsHint } from '../utils/get-parents-hint';
export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000;
const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5;
@ -32,6 +34,49 @@ export class IssuesService {
return res.docs;
}
@CacheTTL(60)
async getIssues(ids: number[]): Promise<RedmineTypes.Issue[]> {
const issueDb = await this.issues.getDatasource();
try {
const docs = await issueDb.find({
selector: {
id: {
$in: ids,
},
},
limit: UNLIMITED,
});
return docs.docs;
} catch (ex) {
return [];
}
}
@CacheTTL(120)
async getParents(
issueId: number,
count?: number,
): Promise<RedmineTypes.Issue[]> {
let index = 0;
const res: RedmineTypes.Issue[] = [];
let currentIssue = await this.getIssue(issueId);
res.unshift(currentIssue);
let parentIssueId = currentIssue.parent?.id || null;
while (
parentIssueId !== null &&
currentIssue.id >= 0 &&
(typeof count === 'undefined' || count === null || index < count)
) {
currentIssue = await this.getIssue(parentIssueId);
res.unshift(currentIssue);
parentIssueId = currentIssue.parent?.id || null;
index++;
}
const parentsHint = GetParentsHint(res);
this.logger.debug(`Parents for issue #${issueId} - ${parentsHint}`);
return res;
}
async getIssue(
issueId: number,
force = false,

View file

@ -26,6 +26,15 @@ export module RedmineTypes {
details?: JournalDetail[];
};
export type ChildIssue = {
id: number;
tracker: IdAndName;
subject: string;
children?: Children;
};
export type Children = ChildIssue[];
export type Issue = {
id: number;
project: IdAndName;
@ -48,6 +57,8 @@ export module RedmineTypes {
closed_on?: string;
relations?: Record<string, any>[];
journals?: Journal[];
children?: Children;
parent?: { id: number };
};
// eslint-disable-next-line @typescript-eslint/prefer-namespace-keyword, @typescript-eslint/no-namespace

View file

@ -24,7 +24,14 @@ export class RedmineDataLoader {
async loadIssue(issueNumber: number): Promise<RedmineTypes.Issue | null> {
const url = this.getIssueUrl(issueNumber);
const resp = await axios.get(url);
let resp;
try {
resp = await axios.get(url);
} catch (ex) {
const errorMsg = ex.message || 'Unknown error';
this.logger.error(`${errorMsg} for url = ${url}`);
throw ex;
}
if (!resp || !resp.data || !resp.data.issue) {
this.logger.error(
`Failed to load issue from redmine, issueNumber = ${issueNumber}`,
@ -34,7 +41,15 @@ export class RedmineDataLoader {
this.logger.debug(
`Loaded issue, issueNumber = ${issueNumber}, subject = ${resp.data.issue.subject}`,
);
return await this.enhancerService.enhanceIssue(resp.data.issue);
let enhancedIssue;
try {
enhancedIssue = await this.enhancerService.enhanceIssue(resp.data.issue);
} catch (ex) {
const errorMsg = ex.message || 'Unknown error';
this.logger.error(`${errorMsg} at enhance issue #${issueNumber}`);
throw ex;
}
return enhancedIssue;
}
async loadUsers(users: number[]): Promise<(RedmineTypes.User | null)[]> {
@ -43,8 +58,19 @@ export class RedmineDataLoader {
}
async loadUser(userNumber: number): Promise<RedmineTypes.User | null> {
if (userNumber <= 0) {
this.logger.warn(`Invalid userNumber = ${userNumber}`);
return null;
}
const url = this.getUserUrl(userNumber);
const resp = await axios.get(url);
let resp;
try {
resp = await axios.get(url);
} catch (ex) {
const errorMsg = ex.message || 'Unknown error';
this.logger.error(`${errorMsg} at load user by url ${url}`);
return null;
}
if (!resp || !resp.data?.user) {
this.logger.error(
`Failed to load user from redmine, userNumber = ${userNumber}`,

View file

@ -64,7 +64,7 @@ export class UsersService {
},
limit: 1,
});
if (!res && !res.docs && !res.docs[0]) {
if (!res || !res.docs || !res.docs[0]) {
return null;
}
const userFromDb = res.docs[0];
@ -72,6 +72,15 @@ export class UsersService {
return RedmineTypes.CreateUser(userFromDb);
}
async findUserByFullname(
fullname: string,
): Promise<RedmineTypes.User | null> {
const parts = fullname.split(' ').map((item) => item.trim());
const lastname = parts.splice(parts.length - 1, 1).join(' ');
const firstname = parts.join(' ');
return await this.findUserByName(firstname, lastname);
}
private async getUserFromRedmine(
userId: number,
): Promise<RedmineTypes.User | null> {

View file

@ -0,0 +1,13 @@
import { RedmineTypes } from '../models/redmine-types';
export function GetParentsHint(issues: RedmineTypes.Issue[]): string {
const parentsHint = issues
.map((issue) => {
if (issue.id < 0) {
return '...';
}
return `${issue.tracker.name} #${issue.id} (${issue.subject})`;
})
.join(' > ');
return parentsHint;
}

49
package-lock.json generated
View file

@ -19,6 +19,7 @@
"axios": "^0.27.2",
"cache-manager": "^4.1.0",
"handlebars": "^4.7.7",
"hbs": "^4.2.0",
"imap-simple": "^5.1.0",
"nano": "^10.0.0",
"node-telegram-bot-api": "^0.59.0",
@ -4864,6 +4865,11 @@
}
}
},
"node_modules/foreachasync": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz",
"integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw=="
},
"node_modules/forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
@ -5343,6 +5349,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hbs": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/hbs/-/hbs-4.2.0.tgz",
"integrity": "sha512-dQwHnrfWlTk5PvG9+a45GYpg0VpX47ryKF8dULVd6DtwOE6TEcYQXQ5QM6nyOx/h7v3bvEQbdn19EDAcfUAgZg==",
"dependencies": {
"handlebars": "4.7.7",
"walk": "2.3.15"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/hexoid": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
@ -9899,6 +9918,14 @@
"node": ">=10"
}
},
"node_modules/walk": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/walk/-/walk-2.3.15.tgz",
"integrity": "sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==",
"dependencies": {
"foreachasync": "^3.0.0"
}
},
"node_modules/walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
@ -14010,6 +14037,11 @@
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz",
"integrity": "sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA=="
},
"foreachasync": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/foreachasync/-/foreachasync-3.0.0.tgz",
"integrity": "sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw=="
},
"forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
@ -14346,6 +14378,15 @@
"has-symbols": "^1.0.2"
}
},
"hbs": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/hbs/-/hbs-4.2.0.tgz",
"integrity": "sha512-dQwHnrfWlTk5PvG9+a45GYpg0VpX47ryKF8dULVd6DtwOE6TEcYQXQ5QM6nyOx/h7v3bvEQbdn19EDAcfUAgZg==",
"requires": {
"handlebars": "4.7.7",
"walk": "2.3.15"
}
},
"hexoid": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
@ -17740,6 +17781,14 @@
"xml-name-validator": "^3.0.0"
}
},
"walk": {
"version": "2.3.15",
"resolved": "https://registry.npmjs.org/walk/-/walk-2.3.15.tgz",
"integrity": "sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==",
"requires": {
"foreachasync": "^3.0.0"
}
},
"walker": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",

View file

@ -31,6 +31,7 @@
"axios": "^0.27.2",
"cache-manager": "^4.1.0",
"handlebars": "^4.7.7",
"hbs": "^4.2.0",
"imap-simple": "^5.1.0",
"nano": "^10.0.0",
"node-telegram-bot-api": "^0.59.0",

View file

@ -4,7 +4,7 @@ import { EnhancerService } from '@app/event-emitter/issue-enhancers/enhancer.ser
import { TimestampEnhancer } from '@app/event-emitter/issue-enhancers/timestamps-enhancer';
import { MainController } from '@app/event-emitter/main/main.controller';
import { CacheModule, Logger, Module, OnModuleInit } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { switchMap, tap } from 'rxjs';
import { AppController } from './app.controller';
import { AppService } from './app.service';
@ -22,8 +22,13 @@ import { TelegramBotService } from './telegram-bot/telegram-bot.service';
import { UserMetaInfoService } from './user-meta-info/user-meta-info.service';
import { UserMetaInfo } from './couchdb-datasources/user-meta-info';
import { PersonalNotificationAdapterService } from './notifications/adapters/personal-notification.adapter/personal-notification.adapter.service';
import { PublicUrlAdapterService } from './notifications/adapters/public-url.adapter.service';
import { StatusChangeAdapterService } from './notifications/adapters/status-change.adapter.service';
import { CurrentIssuesEccmReportService } from './reports/current-issues-eccm.report.service';
import { CurrentIssuesBotHandlerService } from './telegram-bot/handlers/current-issues.bot-handler.service';
import { CurrentIssuesEccmReportController } from './reports/current-issues-eccm.report.controller';
import { DailyEccmReportController } from './reports/daily-eccm.report.controller';
import { DailyEccmReportService } from './reports/daily-eccm.report.service';
import { ChangesService } from './changes/changes.service';
@Module({
imports: [
@ -35,7 +40,12 @@ import { StatusChangeAdapterService } from './notifications/adapters/status-chan
isGlobal: true,
}),
],
controllers: [AppController, MainController],
controllers: [
AppController,
MainController,
CurrentIssuesEccmReportController,
DailyEccmReportController,
],
providers: [
AppService,
CustomFieldsEnhancer,
@ -49,8 +59,11 @@ import { StatusChangeAdapterService } from './notifications/adapters/status-chan
UserMetaInfoService,
UserMetaInfo,
PersonalNotificationAdapterService,
PublicUrlAdapterService,
StatusChangeAdapterService,
CurrentIssuesEccmReportService,
CurrentIssuesBotHandlerService,
DailyEccmReportService,
ChangesService,
],
})
export class AppModule implements OnModuleInit {

View file

@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { Changes } from 'src/couchdb-datasources/changes';
import nano from 'nano';
import { Change } from 'src/models/change.model';
@Injectable()
export class ChangesService {
constructor(private changes: Changes) {}
async find(query: nano.MangoQuery): Promise<Change[]> {
const changesDb = await this.changes.getDatasource();
const res = await changesDb.find(query);
return res.docs;
}
}

View file

@ -5,10 +5,14 @@ import { parse } from 'jsonc-parser';
import { AppConfig } from 'src/models/app-config.model';
import RedmineStatusesConfigLoader from './statuses.config';
import RedmineStatusChangesConfigLoader from './status-changes.config';
import RedmineEccmConfig from './eccm.config';
import RedmineCurrentUserRulesConfig from './current-user-rules.config';
const redmineIssueEventEmitterConfig = RedmineIssueEventEmitterConfigLoader();
const redmineStatusesConfig = RedmineStatusesConfigLoader();
const redmineStatusChanges = RedmineStatusChangesConfigLoader();
const redmineEccm = RedmineEccmConfig();
const redmineCurrentUserRules = RedmineCurrentUserRulesConfig();
let appConfig: AppConfig;
@ -30,6 +34,8 @@ export default (): AppConfig => {
redmineStatuses: redmineStatusesConfig,
redmineIssueEventEmitterConfig: redmineIssueEventEmitterConfig,
redmineStatusChanges: redmineStatusChanges,
redmineEccm: redmineEccm,
redmineCurrentUserRules: redmineCurrentUserRules,
};
return appConfig;

View file

@ -0,0 +1,23 @@
import { join } from 'path';
import { readFileSync } from 'fs';
import { parse } from 'jsonc-parser';
let currentUserRules: Record<string, string>;
export default (): Record<string, string> => {
if (currentUserRules) {
return currentUserRules;
}
const userDefinedConfigPath =
process.env['ELTEX_REDMINE_HELPER_CURRENT_USER_RULES_CONFIG_PATH'];
const defaultConfigPath = join('configs', 'current-user-rules.jsonc');
const configPath = userDefinedConfigPath || defaultConfigPath;
const rawData = readFileSync(configPath, { encoding: 'utf-8' });
currentUserRules = parse(rawData);
return currentUserRules;
};

View file

@ -0,0 +1,24 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { EccmConfig } from 'src/models/eccm-config.model';
import { parse } from 'jsonc-parser';
let eccmVersion: EccmConfig.Config;
export default (): EccmConfig.Config => {
if (eccmVersion) {
return eccmVersion;
}
const userDefinedConfigPath =
process.env['ELTEX_REDMINE_HELPER_ECCM_VERSIONS_CONFIG_PATH'];
const defaultConfigPath = join('configs', 'eccm-config.jsonc');
const configPath = userDefinedConfigPath || defaultConfigPath;
const rawData = readFileSync(configPath, { encoding: 'utf-8' });
eccmVersion = parse(rawData);
return eccmVersion;
};

View file

@ -1,3 +1,4 @@
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@ -14,4 +15,13 @@ export class RedminePublicUrlConverter {
convert(issueId: number | string): string {
return `${this.redminePublicUrlPrefix}/issues/${issueId}`;
}
getUrl(issueId: number | string): string {
return this.convert(issueId);
}
getHtmlHref(issue: RedmineTypes.Issue): string {
const url = this.getUrl(issue.id);
return `<a href="${url}">${issue.tracker.name} #${issue.id}</a>`;
}
}

View file

@ -1,26 +1,23 @@
import { IssueEnhancerInterface } from '@app/event-emitter/issue-enhancers/issue-enhancer-interface';
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class CurrentUserEnhancer implements IssueEnhancerInterface {
name = 'current-user';
// TODO: Переместить правила в конфиг
private rules: Record<string, string>;
private logger = new Logger(CurrentUserEnhancer.name);
private rules = {
New: 'dev',
'In Progress': 'dev',
'Re-opened': 'dev',
'Code Review': 'cr',
Resolved: 'qa',
Testing: 'qa',
'Wait Release': 'dev',
Pending: 'dev',
Feedback: 'qa',
Closed: 'dev',
Rejected: 'dev',
};
constructor(private configService: ConfigService) {
this.rules = this.configService.get<Record<string, string>>(
'redmineCurrentUserRules',
);
this.logger.debug(
`Loaded rules for current user enhancer - ${JSON.stringify(this.rules)}`,
);
}
async enhance(
issue: RedmineTypes.Issue,
@ -29,7 +26,7 @@ export class CurrentUserEnhancer implements IssueEnhancerInterface {
const status = issue.status.name;
const fieldName = this.rules[status];
const fieldName = this.rules[status] || this.rules['*'] || null;
if (fieldName) {
res.current_user = { ...res[fieldName] };
}

View file

@ -1,12 +1,30 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
import * as hbs from 'hbs';
import configuration from './configs/app';
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = "0";
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ['debug', 'error', 'warn', 'log', 'verbose'],
});
app.setBaseViewsDir(join(__dirname, '..', 'views'));
app.setViewEngine('hbs');
// TODO: Продумать как правильно иницировать partial-ы в handlebars
// Возможно подойдёт решение описанное тут - https://www.makeuseof.com/handlebars-nestjs-templating/
// Низкоуровневое решение по исходному коду hbs:
const redminePublicUrl =
configuration().redmineIssueEventEmitterConfig.redmineUrlPublic;
hbs.registerPartial(
'redmineIssueAHref',
`<a href="${redminePublicUrl}/issues/{{issue.id}}">{{issue.tracker.name}} #{{issue.id}}</a>`,
);
await app.listen(process.env['PORT'] || 3000);
}
bootstrap();

View file

@ -1,4 +1,5 @@
import { MainConfigModel } from '@app/event-emitter/models/main-config-model';
import { EccmConfig } from './eccm-config.model';
import { StatusChangesConfig } from './status-changes-config.model';
import { StatusesConfig } from './statuses-config.model';
@ -6,6 +7,8 @@ export type AppConfig = {
redmineIssueEventEmitterConfig: MainConfigModel;
redmineStatuses: StatusesConfig.Config;
redmineStatusChanges: StatusChangesConfig.Config;
redmineEccm: EccmConfig.Config;
redmineCurrentUserRules: Record<string, string>;
couchDb: {
dbs: {
changes: string;

View file

@ -0,0 +1,14 @@
/* eslint-disable @typescript-eslint/no-namespace */
export namespace EccmConfig {
export type UserGroup = {
name: string;
people: string[];
};
export type Config = {
currentVersions: string[];
projectName: string;
currentIssuesStatuses: string[];
groups: UserGroup[];
};
}

View file

@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config';
import { IssueAndPersonalParsedMessageModel } from 'src/models/issue-and-personal-parsed-message.model';
import { TelegramBotService } from 'src/telegram-bot/telegram-bot.service';
import Handlebars from 'handlebars';
import { PublicUrlAdapterService } from '../public-url.adapter.service';
import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter';
@Injectable()
export class PersonalNotificationAdapterService {
@ -14,7 +14,7 @@ export class PersonalNotificationAdapterService {
private telegramBotService: TelegramBotService,
private configService: ConfigService,
private usersService: UsersService,
private publicUrlAdapterService: PublicUrlAdapterService,
private redminePublicUrlConverter: RedminePublicUrlConverter,
) {
const template = this.configService.get<string>('personalMessageTemplate');
this.personalMessageTemplate = Handlebars.compile(template);
@ -26,7 +26,7 @@ export class PersonalNotificationAdapterService {
const promises = issueAndMessages.personalParsedMessage.recipients.map(
async (recipient) => {
const redmineId = recipient;
const issueUrlHtml = this.publicUrlAdapterService.getHtmlHref(
const issueUrlHtml = this.redminePublicUrlConverter.getHtmlHref(
issueAndMessages.issue,
);
const sender = await this.usersService.getUser(

View file

@ -1,21 +0,0 @@
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class PublicUrlAdapterService {
private publicUrl: string;
constructor(private configService: ConfigService) {
this.publicUrl = this.configService.get<string>('redmineUrlPublic');
}
getUrl(issueId: number): string {
return `${this.publicUrl}/issues/${issueId}`;
}
getHtmlHref(issue: RedmineTypes.Issue): string {
const url = this.getUrl(issue.id);
return `<a href="${url}">${issue.tracker.name} #${issue.id}</a>`;
}
}

View file

@ -0,0 +1,60 @@
import { Controller, Get, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { EccmConfig } from 'src/models/eccm-config.model';
import {
CurrentIssuesEccmReport,
CurrentIssuesEccmReportService,
} from './current-issues-eccm.report.service';
@Controller('current-issues-eccm')
export class CurrentIssuesEccmReportController {
private logger = new Logger(CurrentIssuesEccmReportController.name);
private eccmConfig: EccmConfig.Config;
/* eslint-disable */
private reportTemplate = [
// context - this.report: UserReport[]
'{{#each this}}',
'<div>',
// context - UserReport
'<p>{{user.firstname}} {{user.lastname}}:</p>',
'<ul>',
'{{#each issuesGroupedByStatus}}',
'<li>',
// context - IssuesAndStatus
'{{status.name}}:',
'<ul>',
'{{#each issues}}',
'<li>',
// context - RedmineTypes.Issue
`{{>redmineIssueAHref issue=.}}: {{subject}} (прио - {{priority.name}}, версия - {{fixed_version.name}})`,
'</li>',
'{{/each}}',
'</ul>',
'</li>',
'{{/each}}',
'</ul>',
'</div>',
'{{/each}}',
].join('\n');
/* eslint-enable */
constructor(
private currentIssuesEccmReportService: CurrentIssuesEccmReportService,
private configService: ConfigService,
) {
this.eccmConfig = this.configService.get<EccmConfig.Config>('redmineEccm');
this.logger.debug(`Eccm config - ${JSON.stringify(this.eccmConfig)}`);
}
@Get()
async getReport(): Promise<string> {
return await this.currentIssuesEccmReportService.getReport({
project: this.eccmConfig.projectName,
versions: this.eccmConfig.currentVersions,
statuses: this.eccmConfig.currentIssuesStatuses,
template: this.reportTemplate,
fields: CurrentIssuesEccmReport.Defaults.currentIssuesFields,
});
}
}

View file

@ -0,0 +1,231 @@
/* eslint-disable prettier/prettier */
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Issues } from '@app/event-emitter/couchdb-datasources/issues';
import { UNLIMITED } from '@app/event-emitter/consts/consts';
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import nano from 'nano';
import Handlebars from 'handlebars';
import { RedminePublicUrlConverter } from 'src/converters/redmine-public-url.converter';
import { EccmConfig } from 'src/models/eccm-config.model';
import { IssuesService } from '@app/event-emitter/issues/issues.service';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace CurrentIssuesEccmReport {
export type Options = {
statuses?: string[];
versions?: string[];
userIds?: number[];
project?: string;
template?: string;
fields?: string[];
};
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Defaults {
export const statuses = [
'In Progress',
'Feedback',
'Re-opened',
'Code Review',
'Resolved',
'Testing',
];
export const projectName = 'ECCM';
export const currentIssuesFields = [
'id',
'tracker.name',
'status.id',
'status.name',
'priority.id',
'priority.name',
'fixed_version.name',
'subject',
'updated_on',
'updated_on_timestamp',
'current_user.id',
'current_user.firstname',
'current_user.lastname',
];
}
export type User = {
id: number;
firstname: string;
lastname: string;
};
export type Status = {
id: number;
name: string;
};
export type IssuesAndStatus = {
status: Status;
issues: RedmineTypes.Issue[];
};
export type UserReport = {
user: User;
issuesGroupedByStatus: IssuesAndStatus[];
};
export class UsersReport {
private report: UserReport[] = [];
private reportTemplate = [
// context - this.report: UserReport[]
'{{#each this}}',
// context - UserReport
'{{user.firstname}} {{user.lastname}}:',
'',
'{{#each issuesGroupedByStatus}}',
// context - IssuesAndStatus
'{{status.name}}:',
'{{#each issues}}',
// context - RedmineTypes.Issue
` - {{>redmineIssueAHref issue=.}}: {{subject}} (прио - {{priority.name}}, версия - {{fixed_version.name}})`,
'{{/each}}',
'{{/each}}',
'',
'{{/each}}',
].join('\n');
constructor(
private redminePublicUrlConverter: RedminePublicUrlConverter,
private redminePublicUrl: string,
) {
Handlebars.registerPartial(
'redmineIssueAHref',
`<a href="${this.redminePublicUrl}/issues/{{issue.id}}">{{issue.tracker.name}} #{{issue.id}}</a>`,
);
}
push(item: any): void {
const user: User = {
id: item.current_user.id,
firstname: item.current_user.firstname,
lastname: item.current_user.lastname,
};
const status: Status = {
id: item.status.id,
name: item.status.name,
};
const issuesAndStatus = this.getOrCreateIssuesAndStatus(user, status);
issuesAndStatus.issues.push(item);
}
getOrCreateUserReport(user: User): UserReport {
let userReport: UserReport;
userReport = this.report.find((r) => r.user.id == user.id);
if (!userReport) {
userReport = {
user: user,
issuesGroupedByStatus: [],
};
this.report.push(userReport);
}
return userReport;
}
getOrCreateIssuesAndStatus(user: User, status: Status): IssuesAndStatus {
const userReport = this.getOrCreateUserReport(user);
let issuesAndStatus: IssuesAndStatus;
issuesAndStatus = userReport.issuesGroupedByStatus.find(
(i) => i.status.id == status.id,
);
if (!issuesAndStatus) {
issuesAndStatus = {
issues: [],
status: status,
};
userReport.issuesGroupedByStatus.push(issuesAndStatus);
}
return issuesAndStatus;
}
getAllUsersReportByTemplate(template?: string): string {
const reportFormatter = Handlebars.compile(template || this.reportTemplate);
return reportFormatter(this.report);
}
}
}
@Injectable()
export class CurrentIssuesEccmReportService {
private eccmConfig: EccmConfig.Config;
private logger = new Logger(CurrentIssuesEccmReportService.name);
constructor(
private configService: ConfigService,
private issuesDatasource: Issues,
private redminePublicUrlConverter: RedminePublicUrlConverter,
private issuesService: IssuesService,
) {
this.eccmConfig = this.configService.get<EccmConfig.Config>('redmineEccm');
}
async getData(
options?: CurrentIssuesEccmReport.Options,
): Promise<RedmineTypes.Issue[]> {
const datasource = await this.issuesDatasource.getDatasource();
const query: nano.MangoQuery = {
selector: {
'status.name': {
$in: CurrentIssuesEccmReport.Defaults.statuses,
},
'fixed_version.name': {
$in: this.eccmConfig.currentVersions,
},
'project.name':
options?.project || CurrentIssuesEccmReport.Defaults.projectName,
},
limit: UNLIMITED,
};
if (options && options.fields) {
query.fields = options.fields;
}
if (options && options.statuses) {
query.selector['status.name'] = {
$in: options.statuses,
};
}
if (options && options.versions) {
query.selector['fixed_version.name'] = {
$in: options.versions,
};
}
if (options && options.userIds) {
query.selector['current_user.id'] = {
$in: options.userIds,
};
} else {
query.selector['current_user.id'] = {
$exists: true,
};
}
this.logger.debug(`Query for get report data: ${JSON.stringify(query)}`);
const data = await this.issuesService.find(query);
this.logger.debug(`Found issues for report: ${JSON.stringify(data.length)}`);
return data;
}
async getReport(options?: CurrentIssuesEccmReport.Options): Promise<string> {
this.logger.debug(`Options for report: ${JSON.stringify(options || null)}`);
const data = await this.getData(options);
this.logger.debug(`Data for report ${JSON.stringify(data)}`);
const report = new CurrentIssuesEccmReport.UsersReport(
this.redminePublicUrlConverter,
this.configService.get<string>('redmineUrlPublic'),
);
data.forEach((item) => report.push(item));
return report.getAllUsersReportByTemplate(options?.template);
}
}

View file

@ -0,0 +1,37 @@
import { Controller, Get, Query, Render } from '@nestjs/common';
import {
DailyEccmReport,
DailyEccmReportService,
} from './daily-eccm.report.service';
@Controller('daily-eccm')
export class DailyEccmReportController {
constructor(private dailyEccmReportService: DailyEccmReportService) {}
@Get()
@Render('daily-eccm-report')
async getReport(
@Query('from') from: string,
@Query('to') to: string,
): Promise<DailyEccmReport.Models.Report> {
const now = new Date().toISOString();
return await this.dailyEccmReportService.generateReport({
from: from,
to: to,
reportDate: now,
});
}
@Get('/raw')
async getReportRawData(
@Query('from') from: string,
@Query('to') to: string,
): Promise<DailyEccmReport.Models.Report> {
const now = new Date().toISOString();
return await this.dailyEccmReportService.generateReport({
from: from,
to: to,
reportDate: now,
});
}
}

View file

@ -0,0 +1,522 @@
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ChangesService } from 'src/changes/changes.service';
import { EccmConfig } from 'src/models/eccm-config.model';
import { CurrentIssuesEccmReportService } from './current-issues-eccm.report.service';
import nano from 'nano';
import { UNLIMITED } from '@app/event-emitter/consts/consts';
import { TimestampConverter } from '@app/event-emitter/utils/timestamp-converter';
import { IssuesService } from '@app/event-emitter/issues/issues.service';
import { UsersService } from '@app/event-emitter/users/users.service';
import { GetParentsHint } from '@app/event-emitter/utils/get-parents-hint';
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace DailyEccmReport {
// eslint-disable-next-line @typescript-eslint/no-namespace
export namespace Models {
export type User = {
id: number;
firstname: string;
lastname: string;
};
export type Status = {
id: number;
name: string;
};
export type IssuesAndStatus = {
status: Status;
issues: IssueWithParents[];
};
export type UserReport = {
user: User;
dailyMessage?: string;
teamleadMessage?: string;
issuesGroupedByStatus: IssuesAndStatus[];
activities: IssueAndChanges[];
};
export type NewIssues = {
newIssues: IssueWithParents[];
newIssuesCount: number;
};
export type IssuesByStatuses = {
status: string;
issues: IssueWithParents[];
issuesCount: number;
};
export type MetricsByVersion = {
version: string;
newIssues: NewIssues;
issuesByStatuses: IssuesByStatuses[];
};
export type MetricsByParents = {
parentIssueId: number;
parentIssue: RedmineTypes.Issue;
issuesByStatuses: IssuesByStatuses[];
};
/**
* Метрики сгруппированные по версиям
*/
export type Metrics = {
byVersions: MetricsByVersion[];
byParents: MetricsByParents[];
};
/**
* Все данные для отчёта
*/
export type Report = {
byUsers: UserReport[];
metrics?: Metrics;
/** Переданные параметры для создания отчёта */
params: Params;
};
/**
* Данные по задаче + для hint-а подсказка о родительских задачах
*
* Полезно будет для восстановления контекста
*/
export type IssueWithParents = {
issue: RedmineTypes.Issue;
parents: string;
};
/**
* Параметры отчёта
*/
export type Params = {
from: string;
to: string;
reportDate: string;
};
export type IssueAndChanges = {
issue: RedmineTypes.Issue;
parents: string;
changes: Change[];
};
export type Change = {
initiator?: RedmineTypes.PublicUser;
dev?: RedmineTypes.PublicUser;
cr?: RedmineTypes.PublicUser;
qa?: RedmineTypes.PublicUser;
current_user?: RedmineTypes.PublicUser;
author?: RedmineTypes.PublicUser;
issue_id: number;
issue_url: string;
issue_tracker: string;
issue_subject: string;
journal_note?: string;
old_status?: {
id?: number;
name?: string;
} | null;
new_status?: {
id?: number;
name?: string;
};
created_on: string;
created_on_timestamp: number | null;
change_message: string;
recipient: RedmineTypes.PublicUser;
};
}
export class Report {
private report: Models.Report;
private logger = new Logger(Report.name);
constructor(
private eccmUsers: RedmineTypes.User[],
private issuesService: IssuesService,
) {
const users: Models.UserReport[] = this.eccmUsers.map((user) => {
if (!user) {
this.logger.error(`Not found user data`);
return;
}
const u: Models.User = {
id: user.id,
lastname: user.lastname,
firstname: user.firstname,
};
return {
user: u,
activities: [],
issuesGroupedByStatus: [],
} as Models.UserReport;
}).filter((user) => Boolean(user));
this.report = {
byUsers: users,
params: {
from: '',
to: '',
reportDate: '',
},
};
}
get(): Models.Report {
return this.report;
}
setParams(params: Models.Params): void {
this.report.params.from = params.from;
this.report.params.to = params.to;
this.report.params.reportDate = params.reportDate;
}
async setCurrentIssues(issues: RedmineTypes.Issue[]): Promise<void> {
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
await this.appendCurrentIssue(issue);
}
this.logger.debug(`Issues for report loaded successed`);
}
async setActivities(activities: Models.Change[]): Promise<void> {
for (let i = 0; i < activities.length; i++) {
const activity = activities[i];
await this.appendActivity(activity);
}
this.logger.debug(`Activities for report loaded successed`);
}
async appendCurrentIssue(
issue: RedmineTypes.Issue & Record<string, any>,
): Promise<boolean> {
const currentUser: RedmineTypes.PublicUser = issue.current_user;
if (!currentUser) {
return false;
}
const byUser = this.report.byUsers.find(
(item) => item.user.id == currentUser.id,
);
if (!byUser) {
return false;
}
let byStatus: Models.IssuesAndStatus = byUser.issuesGroupedByStatus.find(
(item) => item.status.name == issue.status.name,
);
if (!byStatus) {
byStatus = {
status: { ...issue.status },
issues: [],
};
byUser.issuesGroupedByStatus.push(byStatus);
}
const existsIssue = byStatus.issues.find(
(item) => item.issue.id == issue.id,
);
if (existsIssue) {
return false;
}
const parents = await this.issuesService.getParents(issue.id);
// parents.pop();
const parentsHint = GetParentsHint(parents);
byStatus.issues.push({
issue: issue,
parents: parentsHint,
});
return true;
}
async appendActivity(activity: Models.Change): Promise<boolean> {
const recipient = activity.recipient;
if (!recipient) {
this.logger.debug(
`Recipient is empty for activity ` +
`created_on ${activity.created_on} ` +
`for issue #${activity.issue_id} (${activity.issue_subject})`,
);
return false;
}
const recipientId = recipient.id;
const byUser = this.report.byUsers.find(
(byUser) => byUser.user.id == recipientId,
);
if (!byUser) {
this.logger.log(`Not interested user ${recipient.name}`);
return false;
} else {
this.logger.debug(
`Found user section in report data ` +
`user=${JSON.stringify(byUser.user)}`,
);
}
let issueAndChanges: Models.IssueAndChanges = byUser.activities.find(
(issueAndChanges) => {
return issueAndChanges.issue.id == activity.issue_id;
},
);
if (!issueAndChanges) {
const issue = await this.issuesService.getIssue(activity.issue_id);
if (!issue) {
this.logger.error(`Not found issue data for #${activity.issue_id}`);
return false;
}
const parents = await this.issuesService.getParents(activity.issue_id);
const parentsHint = GetParentsHint(parents);
issueAndChanges = {
issue: issue,
parents: parentsHint,
changes: [],
};
this.logger.debug(
`Created issue section in report data ` +
`user=${JSON.stringify(byUser.user)} ` +
`issue=${JSON.stringify({
id: issueAndChanges.issue.id,
subject: issueAndChanges.issue.subject,
})}`,
);
byUser.activities.push(issueAndChanges);
} else {
this.logger.debug(
`Found issue section in report data ` +
`user=${JSON.stringify(byUser.user)} ` +
`issue=${JSON.stringify({
id: issueAndChanges.issue.id,
subject: issueAndChanges.issue.subject,
})}`,
);
}
issueAndChanges.changes.push(activity);
this.logger.debug(
`Activity pushed into report ` +
`user=${JSON.stringify(byUser.user)} ` +
`issue=${JSON.stringify({
id: issueAndChanges.issue.id,
subject: issueAndChanges.issue.subject,
})} ` +
`activity=${JSON.stringify({
created_on: activity.created_on,
change_message: activity.change_message,
})}`,
);
return true;
}
sortUsers(): void {
this.report.byUsers.sort((a, b) => {
const nameA = `${a.user.firstname} ${a.user.lastname}`;
const nameB = `${b.user.firstname} ${b.user.lastname}`;
if (nameA > nameB) {
return 1;
}
if (nameA < nameB) {
return -1;
}
return 0;
});
}
sortActivities(): void {
for (let i = 0; i < this.report.byUsers.length; i++) {
const byUser = this.report.byUsers[i];
for (let j = 0; j < byUser.activities.length; j++) {
const activity = byUser.activities[j];
activity.changes.sort((a, b) => {
return a.created_on_timestamp - b.created_on_timestamp;
});
}
}
}
}
}
@Injectable()
export class DailyEccmReportService {
private logger = new Logger(DailyEccmReportService.name);
private eccmConfig: EccmConfig.Config;
private report: DailyEccmReport.Report;
constructor(
private configService: ConfigService,
private currentIssuesEccmReportService: CurrentIssuesEccmReportService,
private changesService: ChangesService,
private issuesService: IssuesService,
private usersService: UsersService,
) {
this.eccmConfig = this.configService.get<EccmConfig.Config>('redmineEccm');
}
async getReport(): Promise<DailyEccmReport.Report> {
if (!this.report) {
await this.createEmptyReport();
}
return this.report;
}
async generateReport(
params: DailyEccmReport.Models.Params,
): Promise<DailyEccmReport.Models.Report> {
await this.createEmptyReport();
const report = this.report;
report.setParams(params);
const currentIssues = await this.getCurrentIssues();
this.logger.log(`Current issues count - ${currentIssues.length}`);
await report.setCurrentIssues(currentIssues);
const activities = await this.getActivities(params);
this.logger.log(`Activities count - ${activities.length}`);
await report.setActivities(activities);
report.sortUsers();
report.sortActivities();
this.logger.log(`Daily eccm report created successed`);
return report.get();
}
private async createEmptyReport(): Promise<DailyEccmReport.Report> {
const usersList: string[] = this.eccmConfig.groups.reduce((acc, group) => {
group.people.forEach((fullname) => {
acc.push(fullname);
});
return acc;
}, []);
this.logger.debug(
`Create new daily report for users - ${JSON.stringify(usersList)}`,
);
const usersPromises = usersList.map((fullname) => {
return this.usersService.findUserByFullname(fullname);
});
const users = await Promise.all(usersPromises);
this.report = new DailyEccmReport.Report(users, this.issuesService);
return this.report;
}
private async getCurrentIssues(): Promise<RedmineTypes.Issue[]> {
return await this.currentIssuesEccmReportService.getData({
project: this.eccmConfig.projectName,
statuses: this.eccmConfig.currentIssuesStatuses,
versions: this.eccmConfig.currentVersions,
});
}
private async getActivities(
params: DailyEccmReport.Models.Params,
): Promise<DailyEccmReport.Models.Change[]> {
const fromTimestamp = TimestampConverter.toTimestamp(params.from);
const toTimestamp = TimestampConverter.toTimestamp(params.to);
const query: nano.MangoQuery = {
selector: {
$and: [
{
created_on_timestamp: {
$gte: fromTimestamp,
},
},
{
created_on_timestamp: {
$lt: toTimestamp,
},
},
],
},
limit: UNLIMITED,
};
this.logger.debug(
`Query for get activities data - ${JSON.stringify(query)}`,
);
const changes = await this.changesService.find(query);
this.logger.log(
`Loaded changes for daily report - count = ${changes.length}`,
);
const uniqIssueIds: number[] = [];
changes.forEach((change) => {
const issueId = change.issue_id;
if (!uniqIssueIds.includes(issueId)) {
uniqIssueIds.push(issueId);
}
});
this.logger.debug(`Uniq issues - ${JSON.stringify(uniqIssueIds)}`);
const issues = await this.issuesService.getIssues(uniqIssueIds);
this.logger.debug(`Loaded data for uniq issues - count = ${issues.length}`);
const issuesMap: Record<number, RedmineTypes.Issue> = {};
issues.forEach((issue) => {
if (!issue || !issue.fixed_version) {
return;
}
const version = issue.fixed_version.name;
if (this.eccmConfig.currentVersions.includes(version)) {
issuesMap[issue.id] = issue;
}
});
const reportChanges: DailyEccmReport.Models.Change[] = changes.reduce(
(acc: DailyEccmReport.Models.Change[], change) => {
change.messages.forEach((message) => {
if (!message || !message.recipient || !message.change_message) {
return;
}
const c: DailyEccmReport.Models.Change = {
initiator: change.initiator,
dev: change.dev,
cr: change.cr,
qa: change.qa,
current_user: change.current_user,
author: change.author,
issue_id: change.issue_id,
issue_url: change.issue_url,
issue_tracker: change.issue_tracker,
issue_subject: change.issue_subject,
journal_note: change.journal_note,
old_status: change.old_status && {
id: change.old_status.id,
name: change.old_status.name,
},
new_status: change.new_status && {
id: change.new_status.id,
name: change.new_status.name,
},
created_on: change.created_on,
created_on_timestamp: change.created_on_timestamp,
recipient: {
firstname: message.recipient.firstname,
lastname: message.recipient.lastname,
id: message.recipient.id,
login: message.recipient.login,
name: message.recipient.name,
},
change_message: message.change_message,
};
acc.push(c);
});
return acc;
},
[] as DailyEccmReport.Models.Change[],
);
this.logger.log(
`Prepared activities for report - count = ${reportChanges.length}`,
);
return reportChanges;
}
}

View file

@ -0,0 +1,47 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import TelegramBot from 'node-telegram-bot-api';
import { EccmConfig } from 'src/models/eccm-config.model';
import { CurrentIssuesEccmReport, CurrentIssuesEccmReportService } from 'src/reports/current-issues-eccm.report.service';
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
import { TelegramBotService } from '../telegram-bot.service';
@Injectable()
export class CurrentIssuesBotHandlerService {
private forName = /\/current_issues_eccm (.+) (.+)/;
private forCurrentUser = /\/current_issues_eccm/;
private service: TelegramBotService;
private eccmConfig: EccmConfig.Config;
private logger = new Logger(CurrentIssuesBotHandlerService.name);
constructor(
private configService: ConfigService,
private currentIssuesEccmReportService: CurrentIssuesEccmReportService,
private userMetaInfoService: UserMetaInfoService,
) {
this.eccmConfig = this.configService.get<EccmConfig.Config>('redmineEccm');
}
async init(service: TelegramBotService, bot: TelegramBot): Promise<void> {
if (!this.service) {
this.service = service;
}
bot.onText(/\/current_issues_eccm/, async (msg) => {
const userMetaInfo = await this.userMetaInfoService.findByTelegramId(
msg.chat.id,
);
const redmineUserId = userMetaInfo.user_id;
const report = await this.currentIssuesEccmReportService.getReport({
userIds: [redmineUserId],
project: this.eccmConfig.projectName,
versions: this.eccmConfig.currentVersions,
statuses: this.eccmConfig.currentIssuesStatuses,
fields: CurrentIssuesEccmReport.Defaults.currentIssuesFields,
});
this.logger.debug(`Current issues eccm report: ${report}`);
bot.sendMessage(msg.chat.id, report || 'empty report', {
parse_mode: 'HTML',
});
});
}
}

View file

@ -5,13 +5,13 @@ import TelegramBot from 'node-telegram-bot-api';
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
import axios from 'axios';
import { UserMetaInfoModel } from 'src/models/user-meta-info.model';
import { CurrentIssuesBotHandlerService } from './handlers/current-issues.bot-handler.service';
@Injectable()
export class TelegramBotService {
private logger = new Logger(TelegramBotService.name);
private bot: TelegramBot;
private telegramBotToken: string;
private redmineApiUrlPrefix: string;
private redminePublicUrlPrefix: string;
private registerRe = /\/register (\d+) (.+)/;
@ -20,10 +20,9 @@ export class TelegramBotService {
private userMetaInfoService: UserMetaInfoService,
private usersService: UsersService,
private configService: ConfigService,
private currentIssuesBotHandlerService: CurrentIssuesBotHandlerService,
) {
this.telegramBotToken = this.configService.get<string>('telegramBotToken');
this.redmineApiUrlPrefix =
this.configService.get<string>('redmineUrlPrefix');
this.redminePublicUrlPrefix =
this.configService.get<string>('redmineUrlPublic');
this.initTelegramBot();
@ -44,6 +43,7 @@ export class TelegramBotService {
this.bot.onText(/\/leave/, async (msg) => {
await this.leave(msg);
});
this.currentIssuesBotHandlerService.init(this, this.bot);
}
private async showHelpMessage(msg: TelegramBot.Message): Promise<void> {

View file

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Daily Eccm Report</title>
</head>
<body>
<details>
<summary>Параметры отчёта</summary>
<ul>
<li>От - {{this.params.from}}</li>
<li>До - {{this.params.to}}</li>
<li>Дата выгрузки - {{this.params.reportDate}}</li>
</ul>
</details>
<h1>Отчёт по работникам</h1>
{{#each this.byUsers}}
<h2>{{this.user.firstname}} {{this.user.lastname}}</h2>
<h3>Текущие задачи</h3>
<ul>
{{#each this.issuesGroupedByStatus}}
<li>
{{this.status.name}}:
<ul>
{{#each this.issues}}
<li title="{{this.parents}}">{{>redmineIssueAHref issue=this.issue}} (приоритет {{this.issue.priority.name}}; версия {{this.issue.fixed_version.name}}) - {{this.issue.subject}}</li>
{{/each}}
</ul>
</li>
{{/each}}
</ul>
<h3>Активности за период</h3>
<ul>
{{#each this.activities}}
<li title="{{this.parents}}">
{{>redmineIssueAHref issue=this.issue}} (приоритет {{this.issue.priority.name}}; версия {{this.issue.fixed_version.name}}) - {{this.issue.subject}}
<ul>
{{#each this.changes}}
<li>{{this.created_on}}: {{this.change_message}}</li>
{{/each}}
</ul>
</li>
{{/each}}
</ul>
{{/each}}
</body>
</html>